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
45 changes: 44 additions & 1 deletion src/core/streaming/__tests__/dicomChunkImage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import DicomChunkImage, {
DicomChunkImageInit,
} from '@/src/core/streaming/dicomChunkImage';
import { ChunkStatus } from '@/src/core/streaming/chunkImage';
import {
US_UNIT_CENTIMETERS,
UltrasoundRegions,
} from '@/src/core/streaming/dicom/ultrasoundRegion';

const ROWS = 2;
const COLUMNS = 2;
Expand Down Expand Up @@ -36,13 +40,15 @@ function metadataFor(z: number, overrides: Record<string, string> = {}) {
// slice identify which chunk it came from.
async function makeLoadedChunk(
z: number,
overrides: Record<string, string> = {}
overrides: Record<string, string> = {},
ultrasoundRegions?: UltrasoundRegions
) {
const meta = metadataFor(z, overrides);
const chunk = new Chunk({
metaLoader: {
meta,
metaBlob: new Blob([`meta-${z}`]),
ultrasoundRegions,
load: () => {},
stop: () => {},
},
Expand Down Expand Up @@ -161,6 +167,43 @@ describe('DicomChunkImage', () => {
image.dispose();
});

// PixelSpacing is row\column, so the fallback in-plane spacing is [0.7, 0.6].
it.each([
{ physicalDeltaX: 0.05, expected: [0.5, 0.3], warnings: 0 },
{ physicalDeltaX: 0, expected: [0.7, 0.6], warnings: 1 },
])(
'applies ultrasound region spacing only when nonzero and finite (deltaX $physicalDeltaX)',
async ({ physicalDeltaX, expected, warnings }) => {
const warn = vi.fn();
const image = new DicomChunkImage({
splitAndSort: splitAndSortByPosition,
readDicomImage,
warn,
});
const frame = await makeLoadedChunk(
1,
{ [Tags.Modality]: 'US', [Tags.PixelSpacing]: '0.6\\0.7' },
{
region: {
physicalDeltaX,
physicalDeltaY: 0.03,
physicalUnitsXDirection: US_UNIT_CENTIMETERS,
physicalUnitsYDirection: US_UNIT_CENTIMETERS,
},
regionCount: 1,
}
);

await image.addChunks([frame]);

const [x, y] = image.getVtkImageData().getSpacing();
expect(x).toBeCloseTo(expected[0]);
expect(y).toBeCloseTo(expected[1]);
expect(warn).toHaveBeenCalledTimes(warnings);
image.dispose();
}
);

it('settles after rejecting decoded values its integer buffer cannot hold', async () => {
const message = await loadRejectingSeries(
decodeTo((value) =>
Expand Down
21 changes: 16 additions & 5 deletions src/core/streaming/dicomChunkImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { ensureError } from '@/src/utils';
import { computed } from 'vue';
import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper';
import { unitToMm } from '@/src/core/streaming/dicom/ultrasoundRegion';
import { isUsableSpacing } from '@/src/utils/imageSpace';
import { surfaceWarning } from '@/src/store/messages';

const { fastComputeRange } = vtkDataArray;

Expand Down Expand Up @@ -94,6 +96,7 @@ export interface DicomChunkImageInit {
imageType: Pick<Image['imageType'], 'components'>;
};
}>;
warn: (title: string, details: string) => void;
}

export default class DicomChunkImage
Expand All @@ -102,6 +105,7 @@ export default class DicomChunkImage
{
private splitAndSort: DicomChunkImageInit['splitAndSort'];
private readDicomImage: DicomChunkImageInit['readDicomImage'];
private warn: DicomChunkImageInit['warn'];
protected chunks: Chunk[];
private chunkListeners: Array<() => void>;
private thumbnailCache: WeakMap<Chunk, Promise<string>>;
Expand All @@ -119,6 +123,7 @@ export default class DicomChunkImage

this.splitAndSort = init.splitAndSort ?? splitAndSortChunks;
this.readDicomImage = init.readDicomImage ?? readDicomImage;
this.warn = init.warn ?? surfaceWarning;

this.status.value = 'incomplete';
this.loaded = computed(() => {
Expand Down Expand Up @@ -362,12 +367,18 @@ export default class DicomChunkImage
return;
}

const spacingX = region.physicalDeltaX * xFactor;
const spacingY = region.physicalDeltaY * yFactor;
if (!isUsableSpacing(spacingX) || !isUsableSpacing(spacingY)) {
this.warn(
'Invalid ultrasound calibration',
`The ultrasound region declares PhysicalDeltaX=${region.physicalDeltaX} and PhysicalDeltaY=${region.physicalDeltaY}. Measurements use the image pixel spacing instead.`
);
return;
}

const [, , zSpacing] = this.vtkImageData.value.getSpacing();
this.vtkImageData.value.setSpacing([
region.physicalDeltaX * xFactor,
region.physicalDeltaY * yFactor,
zSpacing,
]);
this.vtkImageData.value.setSpacing([spacingX, spacingY, zSpacing]);
}

private updateDataRangeFromChunks() {
Expand Down
13 changes: 11 additions & 2 deletions src/io/import/processors/importSingleFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { useImageStore } from '@/src/store/datasets-images';
import { useModelStore } from '@/src/store/datasets-models';
import { FILE_READERS } from '@/src/io';
import { ImportHandler, asLoadableResult } from '@/src/io/import/common';
import { useMessageStore } from '@/src/store/messages';
import { surfaceWarning, useMessageStore } from '@/src/store/messages';
import { Skip } from '@/src/utils/evaluateChain';
import { repairUnusableSpacing } from '@/src/utils/imageSpace';

/**
* Reads and imports a file DataSource.
Expand All @@ -25,9 +26,17 @@ const importSingleFile: ImportHandler = async (dataSource) => {
const { dataObject, headerMetadata } = await reader(dataSource.file);

if (dataObject.isA('vtkImageData')) {
const image = dataObject as vtkImageData;
const declared = repairUnusableSpacing(image);
if (declared) {
surfaceWarning(
'Invalid voxel spacing',
`"${dataSource.file.name}" declares voxel spacing ${declared.join(', ')}. Axes with zero or non-finite spacing use 1 instead, so measurements along them are not physical.`
);
}
const dataID = useImageStore().addVTKImageData(
dataSource.file.name,
dataObject as vtkImageData,
image,
{ headerMetadata }
);

Expand Down
10 changes: 8 additions & 2 deletions src/io/readWriteImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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';
import { repairUnusableSpacing } from '@/src/utils/imageSpace';

export type ReadImageResult = {
image: vtkImageData;
Expand All @@ -26,14 +27,19 @@ const getHeaderMetadata = (image: { metadata?: Map<string, unknown> }) => {
return headerMetadata;
};

// Repaired like the parent import so a restored labelmap keeps its grid.
export const readImage = async (file: File): Promise<ReadImageResult> => {
if (file.name.endsWith('.vti')) {
return { image: (await vtiReader(file)) as vtkImageData };
const image = (await vtiReader(file)) as vtkImageData;
repairUnusableSpacing(image);
return { image };
}

const { image } = await readImageItk(file, { webWorker: getWorker() });
const vtkImage = vtkITKHelper.convertItkToVtkImage(image);
repairUnusableSpacing(vtkImage);
return {
image: vtkITKHelper.convertItkToVtkImage(image),
image: vtkImage,
headerMetadata: getHeaderMetadata(image),
};
};
Expand Down
24 changes: 23 additions & 1 deletion src/utils/__tests__/imageSpace.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import { compareImageIndexGrids, compareImageSpaces } from '../imageSpace';
import {
compareImageIndexGrids,
compareImageSpaces,
repairUnusableSpacing,
} from '../imageSpace';

describe('compareImageIndexGrids', () => {
it('distinguishes a reflected index grid from equivalent physical coverage', () => {
Expand Down Expand Up @@ -67,3 +71,21 @@ describe('compareImageIndexGrids', () => {
expect(compareImageIndexGrids(a, b)).toBe(false);
});
});

describe('repairUnusableSpacing', () => {
it('replaces zero and non-finite spacing and keeps negative spacing', () => {
const image = vtkImageData.newInstance();
image.setSpacing([0, -0.5, NaN]);

expect(repairUnusableSpacing(image)).toEqual([0, -0.5, NaN]);
expect(image.getSpacing()).toEqual([1, -0.5, 1]);
});

it('leaves usable spacing unchanged', () => {
const image = vtkImageData.newInstance();
image.setSpacing([0.7, 0.7, 2.5]);

expect(repairUnusableSpacing(image)).toBeNull();
expect(image.getSpacing()).toEqual([0.7, 0.7, 2.5]);
});
});
14 changes: 14 additions & 0 deletions src/utils/imageSpace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ export function compareImageIndexGrids(
);
}

// Negative spacing stays usable because its sign places the voxels.
export const isUsableSpacing = (value: number) =>
Number.isFinite(value) && value !== 0;

// Returns the declared spacing when a repair was needed, otherwise null.
export function repairUnusableSpacing(image: vtkImageData) {
const declared = image.getSpacing();
if (declared.every(isUsableSpacing)) return null;
image.setSpacing(
declared.map((value) => (isUsableSpacing(value) ? value : 1))
);
return declared;
}

/**
* Convert a world point to image index space.
*/
Expand Down
58 changes: 58 additions & 0 deletions tests/specs/zero-voxel-spacing.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as fs from 'fs';
import * as path from 'path';
import { cleanuptotal } from 'wdio-cleanuptotal-service';

import { TEMP_DIR } from '../../wdio.shared.conf';
import { volViewPage } from '../pageobjects/volview.page';
import { writeManifestToFile } from './utils';

const SIZE = 16;

// itk-wasm passes a MetaImage's zero ElementSpacing through unchanged.
function writeZeroSpacingMetaImage() {
const fileName = `zero-voxel-spacing-${Date.now()}.mha`;
const filePath = path.join(TEMP_DIR, fileName);
const header = [
'ObjectType = Image',
'NDims = 3',
`DimSize = ${SIZE} ${SIZE} ${SIZE}`,
'ElementSpacing = 0 1 1',
'ElementType = MET_UCHAR',
'ElementDataFile = LOCAL',
'',
].join('\n');
const voxels = Uint8Array.from({ length: SIZE ** 3 }, (_, i) => i % 256);
fs.writeFileSync(filePath, Buffer.concat([Buffer.from(header), voxels]));

cleanuptotal.addCleanup(async () => {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
});

return fileName;
}

const notificationTitles = async () => {
await volViewPage.notifications.click();
const titles = $$('.message-center .v-expansion-panel-title .header > span');
await titles[0].waitForDisplayed();
return titles.map((title) => title.getText());
};

describe('An image with zero voxel spacing', () => {
it('can be painted and reports the invalid spacing', async () => {
const fileName = writeZeroSpacingMetaImage();
const manifestName = `zero-voxel-spacing-${Date.now()}.json`;
await writeManifestToFile(
{ resources: [{ url: `/tmp/${fileName}`, name: fileName }] },
manifestName
);

await volViewPage.open(`?urls=[tmp/${manifestName}]`);
await volViewPage.waitForViews();
await volViewPage.activatePaint();
const views2D = await volViewPage.getViews2D();
await volViewPage.paintStrokeOnView(views2D[0]);

expect(await notificationTitles()).toEqual(['Invalid voxel spacing']);
});
});
Loading