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
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@sentry/vue": "^10.27.0",
"@thi.ng/api": "^8.12.9",
"@thi.ng/rasterize": "^1.0.171",
"@types/color-name": "^2.0.0",
"@types/cors": "^2.8.19",
"@types/deep-equal": "^1.0.4",
"@types/express": "^5.0.5",
Expand All @@ -65,6 +66,7 @@
"@wdio/spec-reporter": "^9.20.0",
"@wdio/static-server-service": "^9.20.0",
"@wdio/visual-service": "^10.1.0",
"color-name": "^1.1.4",
"comlink": "^4.4.2",
"concurrently": "^10.0.4",
"core-js": "3.47.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
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';

Expand Down Expand Up @@ -53,26 +50,16 @@ describe('labelmap import index alignment', () => {
expect(runWasm).not.toHaveBeenCalled();
});

it('resamples a reflected child onto the parent grid with label interpolation', async () => {
setActivePinia(createPinia());
it('reorients a reflected label child onto the parent grid without resampling', async () => {
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];
const imported = await ensureSameSpace(parent, child, true);

expect(runWasm).toHaveBeenCalledOnce();
expect(runWasm.mock.calls[0][1]).toContain('--label');
expect(runWasm).not.toHaveBeenCalled();
expect(imported.getDirection()).toEqual(parent.getDirection());
expect(imported.getOrigin()).toEqual(parent.getOrigin());
expect(Array.from(imported.getPointData().getScalars().getData())).toEqual([
Expand Down
129 changes: 129 additions & 0 deletions src/io/resample/__tests__/reorientLabelImage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import { reorientLabelImage } from '../reorientLabelImage';

const image = (dimensions: [number, number, number]) => {
const result = vtkImageData.newInstance();
result.setDimensions(dimensions);
result.getPointData().setScalars(
vtkDataArray.newInstance({
values: Uint16Array.from(
{ length: dimensions.reduce((a, b) => a * b, 1) },
(_, i) => i + 1
),
})
);
return result;
};

describe('label-grid reorientation', () => {
it.each([0, 1, 2, 3, 4, 5, 6, 7])(
'preserves every label under axis flips %i',
(flips) => {
const source = image([3, 4, 5]);
const target = image([3, 4, 5]);
const direction: [
number,
number,
number,
number,
number,
number,
number,
number,
number,
] = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const origin: [number, number, number] = [0, 0, 0];
[3, 4, 5].forEach((size, axis) => {
if (flips & (1 << axis)) {
direction[axis * 4] = -1;
origin[axis] = size - 1;
}
});
source.setDirection(direction);
source.setOrigin(origin);
const output = reorientLabelImage(target, source)!;
const values = output.getPointData().getScalars().getData();
for (let k = 0; k < 5; k++)
for (let j = 0; j < 4; j++)
for (let i = 0; i < 3; i++) {
const x = flips & 1 ? 2 - i : i,
y = flips & 2 ? 3 - j : j,
z = flips & 4 ? 4 - k : k;
expect(values[i + 3 * (j + 4 * k)]).toBe(1 + x + 3 * (y + 4 * z));
}
expect(output.getDirection()).toEqual(target.getDirection());
expect(source.getPointData().getScalars().getData()[0]).toBe(1);
}
);

it('permutes unequal axes', () => {
const source = image([3, 4, 5]);
const target = image([4, 3, 5]);
target.setDirection([0, 1, 0, 1, 0, 0, 0, 0, 1]);
const output = reorientLabelImage(target, source)!;
expect(output.getExtent()).toEqual(target.getExtent());
const values = output.getPointData().getScalars().getData();
for (let k = 0; k < 5; k++)
for (let j = 0; j < 3; j++)
for (let i = 0; i < 4; i++)
expect(values[i + 4 * (j + 3 * k)]).toBe(1 + j + 3 * (i + 4 * k));
});

it('defers nonzero extents to the general path', () => {
const source = image([3, 4, 5]);
source.setExtent(1, 3, 2, 5, 3, 7);
expect(reorientLabelImage(source, source)).toBeNull();
});

it('accepts DICOM precision differences without changing labels', () => {
const source = image([3, 4, 5]);
const target = image([3, 4, 5]);
source.setOrigin([0.000004, 0.000004, 0.000012]);
expect([
...reorientLabelImage(target, source)!
.getPointData()
.getScalars()
.getData(),
]).toEqual([...source.getPointData().getScalars().getData()]);
});

it('returns the source itself when it already sits on the target grid', () => {
const spacing: [number, number, number] = [0.7, 0.7, 3];
const origin: [number, number, number] = [-120.1, -98.4, 33.7];
const source = image([3, 4, 5]);
const target = image([3, 4, 5]);
[source, target].forEach((im) => {
im.setSpacing(spacing);
im.setOrigin(origin);
});
expect(reorientLabelImage(target, source)).toBe(source);

// The same geometry laid out along a flipped axis is a different grid and
// still has to go through the reslice.
const flipped = image([3, 4, 5]);
flipped.setSpacing(spacing);
flipped.setOrigin([origin[0] + spacing[0] * 2, origin[1], origin[2]]);
flipped.setDirection([-1, 0, 0, 0, 1, 0, 0, 0, 1]);
const output = reorientLabelImage(target, flipped)!;
expect(output).not.toBe(flipped);
const values = output.getPointData().getScalars().getData();
for (let k = 0; k < 5; k++)
for (let j = 0; j < 4; j++)
for (let i = 0; i < 3; i++)
expect(values[i + 3 * (j + 4 * k)]).toBe(1 + (2 - i) + 3 * (j + 4 * k));
});

it('defers fractional shifts, different sampling, and cropping to interpolation', () => {
const source = image([3, 4, 5]);
const target = image([3, 4, 5]);
source.setOrigin([0.25, 0, 0]);
expect(reorientLabelImage(target, source)).toBeNull();
source.setOrigin([0, 0, 0]);
source.setSpacing([0.5, 1, 1]);
expect(reorientLabelImage(target, source)).toBeNull();
source.setSpacing([1, 1, 1]);
expect(reorientLabelImage(image([1, 4, 5]), source)).toBeNull();
});
});
86 changes: 86 additions & 0 deletions src/io/resample/reorientLabelImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkImageReslice from '@kitware/vtk.js/Imaging/Core/ImageReslice';
import { InterpolationMode } from '@kitware/vtk.js/Imaging/Core/AbstractImageInterpolator/Constants';
import { mat4, vec3 } from 'gl-matrix';

/** Reorder an equivalent voxel grid without interpolating label boundaries. */
export function reorientLabelImage(target: vtkImageData, source: vtkImageData) {
// ImageReslice produces zero-based output extents.
if (
[target, source].some((image) =>
[0, 2, 4].some((axis) => image.getExtent()[axis] !== 0)
)
)
return null;
const matrix = mat4.multiply(
mat4.create(),
source.getWorldToIndex(),
target.getIndexToWorld()
);
const tolerance = 1e-3;
const axes = [0, 1, 2].map((column) => {
const values = [0, 1, 2].map((row) => matrix[4 * column + row]);
const axis = values.findIndex((value) => Math.abs(value) > 0.5);
if (
axis < 0 ||
values.some((value, row) =>
row === axis
? Math.abs(Math.abs(value) - 1) > tolerance
: Math.abs(value) > tolerance
)
)
return -1;
return axis;
});
if (axes.includes(-1) || new Set(axes).size !== 3) return null;
const sourceSize = source.getDimensions();
if (
target.getDimensions().some((size, axis) => size !== sourceSize[axes[axis]])
)
return null;

// Check the entire extent so rounding error cannot accumulate into a shift
// at the far edge. Matching physical bounds alone does not imply equal grids.
const from = target.getExtent();
const to = source.getExtent();
for (let corner = 0; corner < 8; corner++) {
const point = vec3.fromValues(
from[corner & 1],
from[2 + ((corner >> 1) & 1)],
from[4 + ((corner >> 2) & 1)]
);
vec3.transformMat4(point, point, matrix);
if (
[0, 1, 2].some(
(axis) =>
Math.min(
Math.abs(point[axis] - to[axis * 2]),
Math.abs(point[axis] - to[axis * 2 + 1])
) > tolerance
)
)
return null;
}

// The corner check just proved both grids coincide within `tolerance`, so an
// unpermuted, unflipped mapping means the source already sits on the target
// grid. Demanding a bit-exact identity here instead would reslice every real
// image, whose transforms never multiply back to exactly one.
if (axes.every((axis, column) => axis === column && matrix[5 * column] > 0))
return source;

const filter = vtkImageReslice.newInstance();
filter.setOutputOrigin(target.getOrigin());
filter.setOutputSpacing(target.getSpacing());
filter.setOutputDirection(target.getDirection());
filter.setOutputExtent(target.getExtent());
filter.setOutputDimensionality(3);
filter.setTransformInputSampling(false);
filter.setInterpolationMode(InterpolationMode.NEAREST);
try {
filter.setInputData(source);
return filter.getOutputData() as vtkImageData;
} finally {
filter.delete();
}
}
8 changes: 7 additions & 1 deletion src/io/resample/resample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper';
import { compareImageIndexGrids } from '@/src/utils/imageSpace';
import { shallowCopyImageData } from '@/src/utils/vtk-helpers';
import { runWasm } from './itkWasmUtils';
import { reorientLabelImage } from './reorientLabelImage';

export async function resample(fixed: Image, moving: Image, label = false) {
const labelFlag = label ? ['--label'] : [];
Expand All @@ -30,7 +31,12 @@ export async function ensureSameSpace(
) {
// Callers own what they get back and may hand it to something that disposes
// it, so never return the candidate itself.
if (compareImageIndexGrids(target, resampleCandidate)) {
if (label) {
const reoriented = reorientLabelImage(target, resampleCandidate);
if (reoriented === resampleCandidate)
return shallowCopyImageData(resampleCandidate);
if (reoriented) return reoriented;
} else if (compareImageIndexGrids(target, resampleCandidate)) {
return shallowCopyImageData(resampleCandidate);
}
const itkImage = await resample(
Expand Down
Loading
Loading