diff --git a/src/core/streaming/__tests__/dicomChunkImage.spec.ts b/src/core/streaming/__tests__/dicomChunkImage.spec.ts index 10c66f619..8d7c77764 100644 --- a/src/core/streaming/__tests__/dicomChunkImage.spec.ts +++ b/src/core/streaming/__tests__/dicomChunkImage.spec.ts @@ -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; @@ -36,13 +40,15 @@ function metadataFor(z: number, overrides: Record = {}) { // slice identify which chunk it came from. async function makeLoadedChunk( z: number, - overrides: Record = {} + overrides: Record = {}, + ultrasoundRegions?: UltrasoundRegions ) { const meta = metadataFor(z, overrides); const chunk = new Chunk({ metaLoader: { meta, metaBlob: new Blob([`meta-${z}`]), + ultrasoundRegions, load: () => {}, stop: () => {}, }, @@ -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) => diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 86f8b2fd4..a356fb77f 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -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; @@ -94,6 +96,7 @@ export interface DicomChunkImageInit { imageType: Pick; }; }>; + warn: (title: string, details: string) => void; } export default class DicomChunkImage @@ -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>; @@ -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(() => { @@ -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() { diff --git a/src/io/import/processors/importSingleFile.ts b/src/io/import/processors/importSingleFile.ts index c1c4a5037..086c7b408 100644 --- a/src/io/import/processors/importSingleFile.ts +++ b/src/io/import/processors/importSingleFile.ts @@ -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. @@ -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 } ); diff --git a/src/io/readWriteImage.ts b/src/io/readWriteImage.ts index 80d10fb81..4ad9ca1bb 100644 --- a/src/io/readWriteImage.ts +++ b/src/io/readWriteImage.ts @@ -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; @@ -26,14 +27,19 @@ const getHeaderMetadata = (image: { metadata?: Map }) => { return headerMetadata; }; +// Repaired like the parent import so a restored labelmap keeps its grid. export const readImage = async (file: File): Promise => { 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), }; }; diff --git a/src/utils/__tests__/imageSpace.spec.ts b/src/utils/__tests__/imageSpace.spec.ts index 3b72725ff..711212ba5 100644 --- a/src/utils/__tests__/imageSpace.spec.ts +++ b/src/utils/__tests__/imageSpace.spec.ts @@ -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', () => { @@ -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]); + }); +}); diff --git a/src/utils/imageSpace.ts b/src/utils/imageSpace.ts index 7d310745f..9882b8e44 100644 --- a/src/utils/imageSpace.ts +++ b/src/utils/imageSpace.ts @@ -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. */ diff --git a/tests/specs/zero-voxel-spacing.e2e.ts b/tests/specs/zero-voxel-spacing.e2e.ts new file mode 100644 index 000000000..89598fda5 --- /dev/null +++ b/tests/specs/zero-voxel-spacing.e2e.ts @@ -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']); + }); +});