From 1c04b7c612ab2cd10faaaf49473a6ef81f7ccee0 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 11 Sep 2026 13:08:08 -0400 Subject: [PATCH 1/2] fix(io): replace zero voxel spacing when reading images A MetaImage or VTI header can declare zero or non-finite voxel spacing, and an ultrasound region can declare a zero physical delta. Slice views then render blank and the paint brush throws on every pointer move because its stencil scale divides by spacing. Zero or non-finite spacing now becomes 1 on import, with a warning that measurements along that axis are not physical. Labelmaps restored from a session get the same repair so they keep the parent grid. Ultrasound region spacing is applied only when both deltas are nonzero and finite. Negative spacing is kept because its sign places the voxels. --- .../__tests__/dicomChunkImage.spec.ts | 42 +++++++++++++- src/core/streaming/dicomChunkImage.ts | 16 +++-- src/io/import/processors/importSingleFile.ts | 13 ++++- src/io/readWriteImage.ts | 10 +++- src/utils/__tests__/imageSpace.spec.ts | 24 +++++++- src/utils/imageSpace.ts | 14 +++++ tests/specs/zero-voxel-spacing.e2e.ts | 58 +++++++++++++++++++ 7 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 tests/specs/zero-voxel-spacing.e2e.ts diff --git a/src/core/streaming/__tests__/dicomChunkImage.spec.ts b/src/core/streaming/__tests__/dicomChunkImage.spec.ts index 10c66f619..b52af30e1 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,40 @@ 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] }, + { physicalDeltaX: 0, expected: [0.7, 0.6] }, + ])( + 'applies ultrasound region spacing only when nonzero and finite (deltaX $physicalDeltaX)', + async ({ physicalDeltaX, expected }) => { + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage, + }); + 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]); + 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..8c90f9b22 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -35,6 +35,7 @@ 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'; const { fastComputeRange } = vtkDataArray; @@ -362,12 +363,17 @@ export default class DicomChunkImage return; } + const spacingX = region.physicalDeltaX * xFactor; + const spacingY = region.physicalDeltaY * yFactor; + if (!isUsableSpacing(spacingX) || !isUsableSpacing(spacingY)) { + console.warn( + `Ultrasound spacing not applied: PhysicalDeltaX=${region.physicalDeltaX}, PhysicalDeltaY=${region.physicalDeltaY}; both must be nonzero and finite.` + ); + 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']); + }); +}); From 3b5cfba4ea058d7b521030d299142f3fa0889ba7 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 11 Sep 2026 14:23:55 -0400 Subject: [PATCH 2/2] fix(ultrasound): show a warning when a region has no usable spacing A zero or non-finite PhysicalDeltaX or PhysicalDeltaY is broken calibration, and measurements silently fell back to the image pixel spacing. Report it to the user instead of only the console. --- src/core/streaming/__tests__/dicomChunkImage.spec.ts | 9 ++++++--- src/core/streaming/dicomChunkImage.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/core/streaming/__tests__/dicomChunkImage.spec.ts b/src/core/streaming/__tests__/dicomChunkImage.spec.ts index b52af30e1..8d7c77764 100644 --- a/src/core/streaming/__tests__/dicomChunkImage.spec.ts +++ b/src/core/streaming/__tests__/dicomChunkImage.spec.ts @@ -169,14 +169,16 @@ describe('DicomChunkImage', () => { // 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] }, - { physicalDeltaX: 0, expected: [0.7, 0.6] }, + { 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 }) => { + async ({ physicalDeltaX, expected, warnings }) => { + const warn = vi.fn(); const image = new DicomChunkImage({ splitAndSort: splitAndSortByPosition, readDicomImage, + warn, }); const frame = await makeLoadedChunk( 1, @@ -197,6 +199,7 @@ describe('DicomChunkImage', () => { const [x, y] = image.getVtkImageData().getSpacing(); expect(x).toBeCloseTo(expected[0]); expect(y).toBeCloseTo(expected[1]); + expect(warn).toHaveBeenCalledTimes(warnings); image.dispose(); } ); diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 8c90f9b22..a356fb77f 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -36,6 +36,7 @@ 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; @@ -95,6 +96,7 @@ export interface DicomChunkImageInit { imageType: Pick; }; }>; + warn: (title: string, details: string) => void; } export default class DicomChunkImage @@ -103,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>; @@ -120,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(() => { @@ -366,8 +370,9 @@ export default class DicomChunkImage const spacingX = region.physicalDeltaX * xFactor; const spacingY = region.physicalDeltaY * yFactor; if (!isUsableSpacing(spacingX) || !isUsableSpacing(spacingY)) { - console.warn( - `Ultrasound spacing not applied: PhysicalDeltaX=${region.physicalDeltaX}, PhysicalDeltaY=${region.physicalDeltaY}; both must be nonzero and finite.` + this.warn( + 'Invalid ultrasound calibration', + `The ultrasound region declares PhysicalDeltaX=${region.physicalDeltaX} and PhysicalDeltaY=${region.physicalDeltaY}. Measurements use the image pixel spacing instead.` ); return; }