From 7c59ad0fa77bfc5cac78354ade5eeffad9930d0b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Wed, 28 Oct 2026 20:29:45 -0400 Subject: [PATCH 1/5] feat: add en face slab viewing --- docs/configuration_file.md | 5 +- eslint.config.js | 5 + package-lock.json | 1 + package.json | 10 +- patches/@kitware+vtk.js+38.0.4.patch | 132 +++- scripts/apply-patches.mjs | 12 + scripts/samples/convert_jhu_oct.py | 227 +++++++ scripts/samples/jhu-oct-provenance.json | 160 +++++ src/actions/loadUserFiles.ts | 6 +- src/assets/samples/Retinal-Layers.png | Bin 0 -> 51469 bytes src/components/AboutBox.vue | 44 +- src/components/BaseImageProperties.vue | 68 +- src/components/CineViewer.vue | 37 +- src/components/ControlsStripTools.vue | 220 +++--- src/components/ImageDisplayControls.vue | 2 + src/components/LayoutSelector.vue | 2 +- src/components/RenderingModule.vue | 82 ++- src/components/SampleDataBrowser.vue | 19 +- src/components/SliceViewer.vue | 31 +- src/components/SliceViewerOverlay.vue | 4 +- src/components/ViewTypeSwitcher.vue | 27 +- .../__tests__/BaseImageProperties.spec.ts | 104 +++ .../ControlsStripTools.enFace.spec.ts | 123 ++++ .../contrastActionAccessibility.spec.ts | 73 ++ .../tools/crosshairs/CrosshairsWidget.vue | 20 + .../tools/crosshairs/CrosshairsWidget2D.vue | 117 +--- .../tools/crosshairs/useCrosshairsWidget.ts | 54 ++ .../tools/windowing/WindowLevelControls.vue | 181 +++-- src/components/vtk/VtkRenderWindowParent.vue | 5 +- .../vtk/VtkSlabProjectionRepresentation.vue | 119 ++++ src/components/vtk/VtkSliceView.vue | 110 ++- src/components/vtk/VtkSliceViewNavigation.vue | 36 + .../vtk/VtkSliceViewWindowManipulator.vue | 53 +- .../vtk/__tests__/VtkSliceView.spec.ts | 148 ++++ .../VtkSliceViewWindowManipulator.spec.ts | 176 +++++ .../__tests__/useSyncWindowing.spec.ts | 166 +++++ .../__tests__/useWebGLWatchdog.spec.ts | 72 ++ src/composables/useBaseSliceColoring.ts | 3 +- src/composables/useSyncWindowing.ts | 48 +- src/composables/useViewWindowingRanges.ts | 38 ++ src/composables/useVolumeThumbnailing.ts | 1 - src/composables/useWebGLWatchdog.ts | 23 +- src/composables/useWindowingConfig.ts | 8 +- src/config.ts | 11 + src/core/dicomTags.ts | 4 + src/core/progressiveImage.ts | 18 +- src/core/slab/__tests__/geometry.spec.ts | 108 +++ src/core/slab/__tests__/windowRanges.spec.ts | 122 ++++ src/core/slab/geometry.ts | 74 ++ src/core/slab/types.ts | 8 + src/core/slab/windowRanges.ts | 168 +++++ .../dicom/__tests__/octMetadata.spec.ts | 635 ++++++++++++++++++ .../streaming/dicom/dicomFileMetaLoader.ts | 6 +- src/core/streaming/dicom/dicomMetaLoader.ts | 6 +- src/core/streaming/dicom/octMetadata.ts | 310 +++++++++ src/core/streaming/dicom/octMetadataTags.ts | 20 + src/core/thumbnailers/volume-thumbnailer.ts | 6 +- src/core/viewTypes.ts | 7 +- src/core/views/effectiveView.ts | 6 +- src/core/vtk/__tests__/useVtkView.spec.ts | 5 +- src/core/vtk/useVtkRepresentation.ts | 1 + src/core/vtk/useVtkView.ts | 2 +- src/enface/EnFaceControlPanel.vue | 67 ++ src/enface/EnFaceControls.vue | 182 +++++ src/enface/EnFaceStatus.vue | 32 + src/enface/EnFaceViewer.vue | 255 +++++++ src/enface/__tests__/availability.spec.ts | 139 ++++ src/enface/__tests__/controls.spec.ts | 156 +++++ src/enface/__tests__/layout.spec.ts | 142 ++++ src/enface/__tests__/manualVolume.spec.ts | 234 +++++++ src/enface/__tests__/seriesGeometry.spec.ts | 146 ++++ src/enface/__tests__/sliceGeometry.spec.ts | 177 +++++ src/enface/__tests__/store.spec.ts | 94 +++ src/enface/__tests__/summary.spec.ts | 31 + src/enface/__tests__/viewer.spec.ts | 174 +++++ src/enface/__tests__/windowLevel.spec.ts | 548 +++++++++++++++ src/enface/availability.ts | 75 +++ src/enface/sliceGeometry.ts | 41 ++ src/enface/store.ts | 101 +++ src/enface/useEnFaceInput.ts | 112 +++ src/enface/useEnFaceSliceLines.ts | 38 ++ .../__tests__/restoreViewContent.spec.ts | 100 +++ src/io/import/configJson.ts | 4 +- src/io/import/processors/restoreStateFile.ts | 17 +- .../__tests__/serializeResilience.spec.ts | 2 + src/io/state-file/schema.ts | 17 +- src/io/state-file/serialize.ts | 2 + src/referenceLines/ReferenceLineSegments.vue | 57 ++ src/referenceLines/ReferenceLines.vue | 21 +- src/referenceLines/crossings.ts | 2 + src/referenceLines/index.ts | 2 + src/segmentation/components/PaintControls.vue | 16 +- src/store/__tests__/views.spec.ts | 98 +++ src/store/image-stats.ts | 27 +- src/store/tools/__tests__/crosshairs.spec.ts | 68 +- .../__tests__/enFaceCapabilities.spec.ts | 376 +++++++++++ src/store/tools/crosshairs.ts | 37 +- src/store/tools/index.ts | 71 +- src/store/view-configs.ts | 12 +- .../__tests__/derivedWindowing.spec.ts | 476 +++++++++++++ src/store/view-configs/slicing.ts | 11 +- src/store/view-configs/windowing.ts | 143 +++- src/store/views.ts | 7 + src/types/views.ts | 16 +- src/types/vtk-types.ts | 6 +- .../__tests__/allocateImageFromChunks.spec.ts | 33 + src/utils/__tests__/histogram.spec.ts | 42 ++ src/utils/allocateImageFromChunks.ts | 23 +- src/utils/camera.ts | 6 +- src/utils/getViewingVectors.ts | 11 +- src/utils/histogram.ts | 64 +- src/utils/histogram.worker.ts | 4 +- src/utils/imageMetadata.ts | 19 + src/utils/layoutParsing.ts | 33 +- src/utils/rangeThumbLabels.ts | 10 + tests/datasets.ts | 17 + tests/fixtures/label-outline/server.mjs | 27 +- tests/fixtures/oct/README.md | 29 + tests/fixtures/oct/dicom.ts | 179 +++++ tests/fixtures/oct/heidelberg-reference.json | 114 ++++ tests/fixtures/oct/padding.ts | 48 ++ tests/fixtures/oct/reference.json | 75 +++ tests/fixtures/oct/volumes.ts | 222 ++++++ tests/fixtures/volume-texture/index.html | 10 + tests/fixtures/volume-texture/scene.ts | 96 +++ tests/fixtures/volume-texture/server.mjs | 5 + tests/fixtures/vtkFixtureServer.mjs | 25 + tests/pageobjects/volview.page.ts | 73 +- tests/rootHooks.ts | 1 + tests/specs/base-image-display.e2e.ts | 44 +- tests/specs/cine-drag-onto-slot.e2e.ts | 31 +- tests/specs/dicom-primary-selection.e2e.ts | 45 ++ tests/specs/enface-review-interactions.e2e.ts | 55 ++ tests/specs/layer-display-controls.e2e.ts | 22 +- tests/specs/layerDisplayTestUtils.ts | 22 + tests/specs/layout-config.e2e.ts | 5 +- tests/specs/oblique-windowing.e2e.ts | 193 ++++++ tests/specs/oct-en-face.e2e.ts | 298 ++++++++ tests/specs/oct-import.e2e.ts | 195 ++++++ tests/specs/oct-native-dicom.e2e.ts | 373 ++++++++++ tests/specs/oct-navigation.e2e.ts | 382 +++++++++++ tests/specs/octInteractionTestUtils.ts | 209 ++++++ tests/specs/octTestUtils.ts | 601 +++++++++++++++++ tests/specs/projection-camera.e2e.ts | 247 +++++++ tests/specs/projection-contrast.e2e.ts | 327 +++++++++ .../specs/projection-display-controls.e2e.ts | 548 +++++++++++++++ tests/specs/projection-lifecycle.e2e.ts | 114 ++++ tests/specs/projectionTestUtils.ts | 18 + tests/specs/range-number-steppers.e2e.ts | 4 + tests/specs/reference-lines.e2e.ts | 34 +- tests/specs/referenceLinesTestUtils.ts | 27 + tests/specs/sample-rendering.e2e.ts | 14 +- tests/specs/slab-crosshairs.e2e.ts | 320 +++++++++ tests/specs/slab-geometry.e2e.ts | 285 ++++++++ tests/specs/slab-orientation.e2e.ts | 185 +++++ tests/specs/slab-projection.e2e.ts | 504 ++++++++++++++ tests/specs/slab-reference-visibility.e2e.ts | 69 ++ tests/specs/slab-volume-inputs.e2e.ts | 132 ++++ tests/specs/slabContrastTestUtils.ts | 68 ++ tests/specs/slabSamplingTestUtils.ts | 58 ++ tests/specs/stock-reslice-slabs.e2e.ts | 165 +++++ tests/specs/syntheticDicom.ts | 4 +- tests/specs/utils.ts | 31 + tests/specs/volume-texture-updates.e2e.ts | 40 ++ tests/specs/volume-thumbnail-layout.e2e.ts | 111 +-- tests/specs/windowing-config.e2e.ts | 86 ++- tests/unit/imageCacheCleanup.ts | 10 + tests/unit/octFixture.ts | 83 +++ wdio.chrome.conf.ts | 13 +- wdio.shared.conf.ts | 9 +- 170 files changed, 14949 insertions(+), 819 deletions(-) create mode 100644 scripts/apply-patches.mjs create mode 100644 scripts/samples/convert_jhu_oct.py create mode 100644 scripts/samples/jhu-oct-provenance.json create mode 100644 src/assets/samples/Retinal-Layers.png create mode 100644 src/components/__tests__/BaseImageProperties.spec.ts create mode 100644 src/components/__tests__/ControlsStripTools.enFace.spec.ts create mode 100644 src/components/__tests__/contrastActionAccessibility.spec.ts create mode 100644 src/components/tools/crosshairs/CrosshairsWidget.vue create mode 100644 src/components/tools/crosshairs/useCrosshairsWidget.ts create mode 100644 src/components/vtk/VtkSlabProjectionRepresentation.vue create mode 100644 src/components/vtk/VtkSliceViewNavigation.vue create mode 100644 src/components/vtk/__tests__/VtkSliceView.spec.ts create mode 100644 src/components/vtk/__tests__/VtkSliceViewWindowManipulator.spec.ts create mode 100644 src/composables/__tests__/useSyncWindowing.spec.ts create mode 100644 src/composables/__tests__/useWebGLWatchdog.spec.ts create mode 100644 src/composables/useViewWindowingRanges.ts create mode 100644 src/core/slab/__tests__/geometry.spec.ts create mode 100644 src/core/slab/__tests__/windowRanges.spec.ts create mode 100644 src/core/slab/geometry.ts create mode 100644 src/core/slab/types.ts create mode 100644 src/core/slab/windowRanges.ts create mode 100644 src/core/streaming/dicom/__tests__/octMetadata.spec.ts create mode 100644 src/core/streaming/dicom/octMetadata.ts create mode 100644 src/core/streaming/dicom/octMetadataTags.ts create mode 100644 src/enface/EnFaceControlPanel.vue create mode 100644 src/enface/EnFaceControls.vue create mode 100644 src/enface/EnFaceStatus.vue create mode 100644 src/enface/EnFaceViewer.vue create mode 100644 src/enface/__tests__/availability.spec.ts create mode 100644 src/enface/__tests__/controls.spec.ts create mode 100644 src/enface/__tests__/layout.spec.ts create mode 100644 src/enface/__tests__/manualVolume.spec.ts create mode 100644 src/enface/__tests__/seriesGeometry.spec.ts create mode 100644 src/enface/__tests__/sliceGeometry.spec.ts create mode 100644 src/enface/__tests__/store.spec.ts create mode 100644 src/enface/__tests__/summary.spec.ts create mode 100644 src/enface/__tests__/viewer.spec.ts create mode 100644 src/enface/__tests__/windowLevel.spec.ts create mode 100644 src/enface/availability.ts create mode 100644 src/enface/sliceGeometry.ts create mode 100644 src/enface/store.ts create mode 100644 src/enface/useEnFaceInput.ts create mode 100644 src/enface/useEnFaceSliceLines.ts create mode 100644 src/io/import/__tests__/restoreViewContent.spec.ts create mode 100644 src/referenceLines/ReferenceLineSegments.vue create mode 100644 src/store/tools/__tests__/enFaceCapabilities.spec.ts create mode 100644 src/store/view-configs/__tests__/derivedWindowing.spec.ts create mode 100644 src/utils/__tests__/histogram.spec.ts create mode 100644 src/utils/imageMetadata.ts create mode 100644 src/utils/rangeThumbLabels.ts create mode 100644 tests/fixtures/oct/README.md create mode 100644 tests/fixtures/oct/dicom.ts create mode 100644 tests/fixtures/oct/heidelberg-reference.json create mode 100644 tests/fixtures/oct/padding.ts create mode 100644 tests/fixtures/oct/reference.json create mode 100644 tests/fixtures/oct/volumes.ts create mode 100644 tests/fixtures/volume-texture/index.html create mode 100644 tests/fixtures/volume-texture/scene.ts create mode 100644 tests/fixtures/volume-texture/server.mjs create mode 100644 tests/fixtures/vtkFixtureServer.mjs create mode 100644 tests/specs/dicom-primary-selection.e2e.ts create mode 100644 tests/specs/enface-review-interactions.e2e.ts create mode 100644 tests/specs/oblique-windowing.e2e.ts create mode 100644 tests/specs/oct-en-face.e2e.ts create mode 100644 tests/specs/oct-import.e2e.ts create mode 100644 tests/specs/oct-native-dicom.e2e.ts create mode 100644 tests/specs/oct-navigation.e2e.ts create mode 100644 tests/specs/octInteractionTestUtils.ts create mode 100644 tests/specs/octTestUtils.ts create mode 100644 tests/specs/projection-camera.e2e.ts create mode 100644 tests/specs/projection-contrast.e2e.ts create mode 100644 tests/specs/projection-display-controls.e2e.ts create mode 100644 tests/specs/projection-lifecycle.e2e.ts create mode 100644 tests/specs/projectionTestUtils.ts create mode 100644 tests/specs/referenceLinesTestUtils.ts create mode 100644 tests/specs/slab-crosshairs.e2e.ts create mode 100644 tests/specs/slab-geometry.e2e.ts create mode 100644 tests/specs/slab-orientation.e2e.ts create mode 100644 tests/specs/slab-projection.e2e.ts create mode 100644 tests/specs/slab-reference-visibility.e2e.ts create mode 100644 tests/specs/slab-volume-inputs.e2e.ts create mode 100644 tests/specs/slabContrastTestUtils.ts create mode 100644 tests/specs/slabSamplingTestUtils.ts create mode 100644 tests/specs/stock-reslice-slabs.e2e.ts create mode 100644 tests/specs/volume-texture-updates.e2e.ts create mode 100644 tests/unit/imageCacheCleanup.ts create mode 100644 tests/unit/octFixture.ts diff --git a/docs/configuration_file.md b/docs/configuration_file.md index cfe664980..2f574e207 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -37,7 +37,7 @@ Use a 2D array of view type strings to specify both the grid layout and which vi } ``` -Available view type strings: `"axial"`, `"coronal"`, `"sagittal"`, `"volume"`, `"oblique"` +Available view type strings: `"axial"`, `"coronal"`, `"sagittal"`, `"volume"`, `"oblique"`, `"enface"` ### 2. Simple Grid (gridSize) @@ -111,6 +111,7 @@ View object properties: - 2D views: `type: "2D"`, `orientation: "Axial" | "Coronal" | "Sagittal"`, `name` (optional) - 3D views: `type: "3D"`, `viewDirection` (optional), `viewUp` (optional), `name` (optional) - Oblique views: `type: "Oblique"`, `name` (optional) +- En face views: `type: "EnFace"`, `name` (optional) ### Multiple Layouts Example @@ -147,7 +148,7 @@ Use `disabledViewTypes` to prevent certain view types from being available in th } ``` -This removes the specified view types from the dropdown menu and replaces them in the default layout with allowed types. Valid values: `"2D"`, `"3D"`, `"Oblique"` +This removes the specified view types from the dropdown menu and replaces them in the default layout with allowed types. Valid values: `"2D"`, `"3D"`, `"Oblique"`, `"EnFace"`. ## Segments diff --git a/eslint.config.js b/eslint.config.js index 3476de387..e64605c27 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -310,6 +310,11 @@ export default tseslint.config( }, }, ...featureBoundaries([ + { + dir: 'enface', + publicSurface: false, + pure: ['src/enface/sliceGeometry.ts'], + }, { dir: 'segmentation', pure: [ diff --git a/package-lock.json b/package-lock.json index 50b1f1316..c58d00237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.6.2", "fast-deep-equal": "^3.1.3", + "fflate": "0.7.5", "file-saver": "^2.0.5", "gl-matrix": "3.4.3", "globals": "^16.2.0", diff --git a/package.json b/package.json index e8f6351b0..a7ef1682f 100644 --- a/package.json +++ b/package.json @@ -10,17 +10,18 @@ "backend-contract" ], "scripts": { + "prepare": "node scripts/apply-patches.mjs", "dev": "cross-env VITE_SHOW_SAMPLE_DATA=true vite", "preview": "vite preview", "serve": "npm run dev", - "prebuild": "patch-package", + "prebuild": "patch-package --error-on-fail", "contract:generate": "tsx backend-contract/scripts/generate-json-schema.ts && tsx backend-contract/scripts/generate-openapi.ts", "build": "vue-tsc --noEmit && vite build", "build:analyze": "cross-env ANALYZE_BUNDLE=1 npm run build", "test": "vitest", - "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && wdio run ./wdio.chrome.conf.ts", - "test:e2e:chrome:skip-build": "wdio run ./wdio.chrome.conf.ts", - "test:e2e:dev": "cross-env VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"", + "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts", + "test:e2e:chrome:skip-build": "cross-env HEADED=0 wdio run ./wdio.chrome.conf.ts", + "test:e2e:dev": "cross-env HEADED=0 VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"", "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\"", "build:all": "npm run build:dicom && npm run build:resample && npm run build", "build:dicom": "itk-wasm -s src/io/itk-dicom/ build ", @@ -78,6 +79,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.6.2", "fast-deep-equal": "^3.1.3", + "fflate": "0.7.5", "file-saver": "^2.0.5", "gl-matrix": "3.4.3", "globals": "^16.2.0", diff --git a/patches/@kitware+vtk.js+38.0.4.patch b/patches/@kitware+vtk.js+38.0.4.patch index a5f898acc..77bfe8b00 100644 --- a/patches/@kitware+vtk.js+38.0.4.patch +++ b/patches/@kitware+vtk.js+38.0.4.patch @@ -1,8 +1,124 @@ +diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js +--- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js ++++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js +@@ -440,11 +440,12 @@ + if (program.isUniformUsed(uniformName)) { + const imageData = model.currentValidInputs[i].imageData; + const dim = imageData.getDimensions(); ++ const extent = imageData.getExtent(); + mat4.copy(model.tmpMat4, imageData.getIndexToWorld()); + mat4.translate(model.tmpMat4, model.tmpMat4, [ +- -.5, +- -.5, +- -.5 ++ extent[0] - .5, ++ extent[2] - .5, ++ extent[4] - .5 + ]); + mat4.scale(model.tmpMat4, model.tmpMat4, dim); + mat4.invert(model.tmpMat4, model.tmpMat4); +@@ -574,7 +575,7 @@ + let needRebuild = false; + if (!model.currentRenderPass && model.lastRenderPassShaderReplacement || model.currentRenderPass && model.currentRenderPass.getShaderReplacement() !== model.lastRenderPassShaderReplacement) needRebuild = true; + const numValidInputs = model.currentValidInputs?.length ?? 0; +- if (needRebuild || model.lastHaveSeenDepthRequest !== model.haveSeenDepthRequest || model.lastNumberOfComponents !== model.numberOfComponents || model.lastMultiTexturePerVolumeEnabled !== model.multiTexturePerVolumeEnabled || cellBO.getProgram()?.getHandle() === 0 || model.lastIndependentComponents !== iComp || model.lastUseLabelOutline !== useLabelOutline || model.lastNumValidInputs !== numValidInputs || model.lastSlabThickness !== slabTh || model.lastSlabType !== slabType || model.lastSlabTrapezoidIntegration !== slabTrap) { ++ if (needRebuild || model.lastHaveSeenDepthRequest !== model.haveSeenDepthRequest || model.lastNumberOfComponents !== model.numberOfComponents || model.lastMultiTexturePerVolumeEnabled !== model.multiTexturePerVolumeEnabled || cellBO.getProgram()?.getHandle() === 0 || model.lastIndependentComponents !== iComp || model.lastUseLabelOutline !== useLabelOutline || model.lastNumValidInputs !== numValidInputs || (model.lastSlabThickness > 0) !== (slabTh > 0) || model.lastSlabType !== slabType || model.lastSlabTrapezoidIntegration !== slabTrap) { + model.lastHaveSeenDepthRequest = model.haveSeenDepthRequest; + model.lastNumberOfComponents = model.numberOfComponents; + model.lastMultiTexturePerVolumeEnabled = model.multiTexturePerVolumeEnabled; +@@ -724,33 +725,25 @@ + } + function getSlabSampleLoopLines() { + return [ +- "// Get the first and last samples", ++ "// The center sample is already included in tvalue.", + "int numSlices = 1;", +- "float scaling = min(min(spacing.x, spacing.y), spacing.z) * 0.5;", ++ "float scaling = min(min(abs(spacing.x), abs(spacing.y)), abs(spacing.z)) * 0.5;", ++ "float halfThickness = slabThickness * 0.5;", ++ "// Independently rounded thickness and spacing can differ at an endpoint.", ++ "float boundaryTolerance = halfThickness * 4.76837158203125e-7;", + "vec3 slabNormal = normalize(normalWCVSOutput);", +- "vec3 normalxspacing = scaling * slabNormal;", +- "float distTraveled = length(normalxspacing);", +- "int trapezoid = 0;", +- "// Each march direction leaves the volume for good once it exits the", +- "// unit cube (the sample positions are monotonic along a line and the", +- "// cube is convex), so the loop can stop as soon as both directions", +- "// have exited. Slab thicknesses larger than the volume then only cost", +- "// the in-volume portion of the march.", ++ "int stepIndex = 1;", ++ "float distTraveled = min(scaling, halfThickness);", ++ "// A ray cannot reenter the convex volume after leaving it.", + "bool negExited = false;", + "bool posExited = false;", +- "while (distTraveled < slabThickness * 0.5)", ++ "while (distTraveled > 0.0 && distTraveled <= halfThickness)", + "{", +- " distTraveled += length(normalxspacing);", +- " float fnumSlices = float(numSlices);", +- " bool atSlabBoundary = false;", +- " if (distTraveled > slabThickness * 0.5)", +- " {", +- " // Before stepping outside the slab, sample at the boundaries", +- " normalxspacing = slabNormal * slabThickness * 0.5 / fnumSlices;", +- " trapezoid = slabTrapezoid;", +- " atSlabBoundary = true;", +- " }", +- " vec3 worldPosNeg = vertexWCVSOutput.xyz - fnumSlices * normalxspacing * vboScaling;", ++ " bool atSlabBoundary = halfThickness - distTraveled <= boundaryTolerance;", ++ " float sampleDistance = atSlabBoundary ? halfThickness : distTraveled;", ++ " int trapezoid = atSlabBoundary ? slabTrapezoid : 0;", ++ " vec3 slabOffset = sampleDistance * slabNormal * vboScaling;", ++ " vec3 worldPosNeg = vertexWCVSOutput.xyz - slabOffset;", + " vec3 fragTCoordNeg = (WCTCMatrix0 * vec4(worldPosNeg, 1.0)).xyz;", + " if (!any(greaterThan(fragTCoordNeg, vec3(1.0))) && !any(lessThan(fragTCoordNeg, vec3(0.0))))", + " {", +@@ -758,11 +751,11 @@ + " tvalue = compositeValue(tvalue, newVal, trapezoid);", + " numSlices += 1;", + " }", +- " else if (!atSlabBoundary)", ++ " else", + " {", + " negExited = true;", + " }", +- " vec3 worldPosPos = vertexWCVSOutput.xyz + fnumSlices * normalxspacing * vboScaling;", ++ " vec3 worldPosPos = vertexWCVSOutput.xyz + slabOffset;", + " vec3 fragTCoordPos = (WCTCMatrix0 * vec4(worldPosPos, 1.0)).xyz;", + " if (!any(greaterThan(fragTCoordPos, vec3(1.0))) && !any(lessThan(fragTCoordPos, vec3(0.0))))", + " {", +@@ -770,11 +763,15 @@ + " tvalue = compositeValue(tvalue, newVal, trapezoid);", + " numSlices += 1;", + " }", +- " else if (!atSlabBoundary)", ++ " else", + " {", + " posExited = true;", + " }", +- " if (negExited && posExited) { break; }", ++ " if (atSlabBoundary || (negExited && posExited)) { break; }", ++ " stepIndex += 1;", ++ " float nextDistance = min(float(stepIndex) * scaling, halfThickness);", ++ " if (nextDistance <= distTraveled) { break; }", ++ " distTraveled = nextDistance;", + "}", + "// Finally, if slab type is *mean*, divide the sum by the numSlices", + "if (slabType == 2)", +@@ -1155,7 +1152,7 @@ + let FSSource = shaders.Fragment; + const slabThickness = model.renderable.getSlabThickness(); + let posVCVSDec = ["attribute vec4 vertexWC;", "varying vec4 vertexWCVSOutput;"]; +- posVCVSDec = posVCVSDec.concat([`//${publicAPI.getMTime()}${model.resliceGeomUpdateString}`]); ++ posVCVSDec = posVCVSDec.concat([`//${publicAPI.getMTime()}`]); + if (slabThickness > 0) posVCVSDec = posVCVSDec.concat(["attribute vec3 normalWC;", "varying vec3 normalWCVSOutput;"]); + VSSource = ShaderProgram_default.substitute(VSSource, "//VTK::PositionVC::Dec", posVCVSDec).result; + let posVCVSImpl = ["gl_Position = MCPCMatrix * vertexWC;", "vertexWCVSOutput = vertexWC;"]; diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js -index ba92841..a59d269 100644 --- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js +++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/Renderer.js -@@ -87,7 +87,7 @@ function vtkOpenGLRenderer(publicAPI, model) { +@@ -87,7 +87,7 @@ publicAPI.clear = () => { let clearMask = 0; const gl = model.context; @@ -11,3 +127,15 @@ index ba92841..a59d269 100644 const background = model.renderable.getBackgroundByReference(); gl.clearColor(background[0], background[1], background[2], background[3]); clearMask |= gl.COLOR_BUFFER_BIT; +diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js +--- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js ++++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/VolumeMapper.js +@@ -868,7 +868,7 @@ + const reBuildTex = !tex?.oglObject?.getHandle() || tex?.hash !== scalarsHash; + const updatedExtents = volumeProperty.getUpdatedExtents(); + const hasUpdatedExtents = !!updatedExtents.length; +- if (reBuildTex && !hasUpdatedExtents) { ++ if (reBuildTex && (!hasUpdatedExtents || !tex?.oglObject?.getHandle())) { + const newScalarTexture = Texture_default.newInstance(); + newScalarTexture.setOpenGLRenderWindow(model._openGLRenderWindow); + const dims = imageData.getDimensions(); diff --git a/scripts/apply-patches.mjs b/scripts/apply-patches.mjs new file mode 100644 index 000000000..ce6e52625 --- /dev/null +++ b/scripts/apply-patches.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +if (process.env.NODE_ENV !== 'production') { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.resolve('patch-package')), '--error-on-fail'], + { stdio: 'inherit' } + ); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} diff --git a/scripts/samples/convert_jhu_oct.py b/scripts/samples/convert_jhu_oct.py new file mode 100644 index 000000000..c2ea2b1c3 --- /dev/null +++ b/scripts/samples/convert_jhu_oct.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Convert the JHU hc01 OCT image and manual boundaries into a data-only ZIP. + +Requires NumPy and SciPy. Supply the original .vol and .mat files from the +archive identified in jhu-oct-provenance.json. Outputs contain local scan +coordinates and no patient header fields. The optional validity image stays +outside the ZIP, which contains only the image and named segmentation. +""" + +import argparse +import gzip +import hashlib +import json +from pathlib import Path +import struct +import zipfile + +import numpy as np +from scipy.io import loadmat + + +NAMES = ["Retinal nerve fiber layer (RNFL)", "Ganglion cell and inner plexiform layers (GCL+IPL)", + "Inner nuclear layer (INL)", "Outer plexiform layer (OPL)", "Outer nuclear layer (ONL)", + "Photoreceptor inner segments (IS)", "Photoreceptor outer segments (OS)", + "Retinal pigment epithelium (RPE)"] +COLORS = [(230, 70, 70), (245, 160, 40), (225, 210, 50), (85, 190, 90), + (55, 185, 210), (80, 115, 225), (165, 90, 210), (235, 105, 180)] +COLUMNS = [0, 1, 3, 4, 5, 6, 7, 8, 9] + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def interpolate_controls(controls, width): + """Use the source reader's natural cubic in unit parameter intervals.""" + controls = controls[np.argsort(controls[:, 0], kind="stable")] + x, indices = np.unique(controls[:, 0], return_index=True) + y = controls[indices, 1] + if len(x) < 2 or not np.all(x == np.floor(x)): + raise ValueError("Expected at least two integer lateral control positions") + n = len(y) - 1 + gamma, delta, derivative = (np.zeros(n + 1) for _ in range(3)) + gamma[0] = 0.5 + for i in range(1, n): + gamma[i] = 1 / (4 - gamma[i - 1]) + gamma[-1] = 1 / (2 - gamma[-2]) + delta[0] = 3 * (y[1] - y[0]) * gamma[0] + for i in range(1, n): + delta[i] = (3 * (y[i + 1] - y[i - 1]) - delta[i - 1]) * gamma[i] + delta[-1] = (3 * (y[-1] - y[-2]) - delta[-2]) * gamma[-1] + derivative[-1] = delta[-1] + for i in range(n - 1, -1, -1): + derivative[i] = delta[i] - gamma[i] * derivative[i + 1] + values = np.zeros(int(x[-1] - x[0] + 1)) + for i in range(n): + left, right = int(x[i] - x[0]), int(x[i + 1] - x[0]) + t = np.arange(1, right - left) / (right - left) + c2 = 3 * (y[i + 1] - y[i]) - 2 * derivative[i] - derivative[i + 1] + c3 = -2 * (y[i + 1] - y[i]) + derivative[i] + derivative[i + 1] + values[left] = y[i] + values[left + 1:right] = y[i] + derivative[i] * t + c2 * t * t + c3 * t * t * t + values[-1] = y[-1] + return values[np.clip(np.arange(1, width + 1) - int(x[0]), 0, len(values) - 1)] + + +def read_sources(volume_path, annotation_path): + with volume_path.open("rb") as source: + header = source.read(2048) + width, frames, depth = struct.unpack_from("= 0) & (raw <= 1) + native_invalid += int((~good).sum()) + image[frame] = np.sqrt(np.sqrt(np.where(good[:, ::2], raw[:, ::2], 0))) + valid[frame] = good[:, ::2] + controls = loadmat(annotation_path, variable_names=["control_pts"])["control_pts"] + if controls.shape != (frames, 11): + raise ValueError("Expected 49 frames with eleven annotation slots") + boundaries = np.zeros((frames, width, 9)) + crossing_clamps = 0 + for frame in range(frames): + for boundary, column in enumerate(COLUMNS): + curve = interpolate_controls(controls[frame, column], width) + if boundary: + crossing_clamps += int((curve < boundaries[frame, :, boundary - 1]).sum()) + curve = np.maximum(curve, boundaries[frame, :, boundary - 1]) + boundaries[frame, :, boundary] = curve + return image, valid, boundaries, np.array(coordinates), [lateral, axial], native_invalid, crossing_clamps + + +def regularize(image, valid, boundaries, coordinates, spacing): + frames, depth, width = image.shape + native_x = np.arange(0, width * 2, 2) + fraction = native_x / (width * 2 - 1) + slow = coordinates[0, 1] - (coordinates[:, 1, None] + fraction * (coordinates[:, 3] - coordinates[:, 1])[:, None]) + if not np.all(np.diff(slow, axis=0) > 0): + raise ValueError("Source B-scans must have increasing slow-axis positions") + target = np.linspace(slow[0, 0], slow[-1, 0], frames) + lower = np.empty((frames, width), np.int32) + upper = np.empty_like(lower) + weight = np.empty((frames, width)) + physical = np.empty((frames, width), bool) + regular_boundaries = np.empty((frames, width, 9)) + for x in range(width): + positions = slow[:, x] + physical[:, x] = (target >= positions[0]) & (target <= positions[-1]) + lo = np.clip(np.searchsorted(positions, target, side="right") - 1, 0, frames - 1) + hi = np.minimum(lo + 1, frames - 1) + t = np.divide(target - positions[lo], positions[hi] - positions[lo], out=np.zeros(frames), where=hi != lo) + t = np.clip(t, 0, 1) + t[np.abs(t) < 1e-10] = 0 + t[np.abs(1 - t) < 1e-10] = 1 + lower[:, x], upper[:, x], weight[:, x] = lo, hi, t + regular_boundaries[:, x] = (1 - t[:, None]) * boundaries[lo, native_x[x]] + t[:, None] * boundaries[hi, native_x[x]] + output = np.empty_like(image) + validity = np.empty_like(image, dtype=np.uint8) + labels = np.zeros_like(image, dtype=np.uint8) + complete = np.empty((frames, width), bool) + for frame in range(frames): + x = np.arange(width) + lo, hi, t = lower[frame], upper[frame], weight[frame] + good = ((t[:, None] == 1) | valid[lo, :, x]) & ((t[:, None] == 0) | valid[hi, :, x]) & physical[frame, :, None] + values = (1 - t[:, None]) * image[lo, :, x].astype(np.float64) + t[:, None] * image[hi, :, x].astype(np.float64) + output[frame] = np.where(good, values, 0).T + validity[frame] = good.T + rounded = np.floor(regular_boundaries[frame] + 0.5).astype(np.int32) + if rounded.min() < 0 or rounded.max() > depth or np.any(np.diff(rounded, axis=1) < 0): + raise ValueError("Invalid rounded retinal boundary ordering") + layer = np.zeros((depth, width), np.uint8) + rows = np.arange(depth)[:, None] + # The author assigns one-based rows upper+1 through lower inclusive. + for segment in range(8): + layer[(rows >= rounded[:, segment]) & (rows < rounded[:, segment + 1])] = segment + 1 + complete[frame] = ~np.any((layer > 0) & ~good.T, axis=0) & physical[frame] + labels[frame] = np.where(complete[frame][None, :], layer, 0) + return output, labels, validity, [spacing[0] * 2, spacing[1], float(target[1] - target[0])], complete + + +def write_nrrd(path, data, spacing, provenance, metadata): + attribution = provenance["citation"] + lines = ["NRRD0005", "# Local scan coordinates; anatomical patient orientation is not inferred.", + "type: " + ("float" if data.dtype == np.float32 else "unsigned char"), + "dimension: 3", "space dimension: 3", "sizes: " + " ".join(map(str, data.shape[::-1])), + f"space directions: ({spacing[0]:.17g},0,0) (0,{spacing[1]:.17g},0) (0,0,{spacing[2]:.17g})", + "kinds: domain domain domain", "endian: little", "encoding: gzip", + 'space units: "mm" "mm" "mm"', "space origin: (0,0,0)", + "# Source: " + provenance["archive_url"], "# Paper: " + provenance["paper_url"], + "# Attribution: " + attribution, + *["# SHA256 " + source["filename"] + " " + source["sha256"] for source in provenance["sources"]], + "OCT_AxisMeaning:=lateral depth slow", "OCT_DepthAxis:=1", + "OCT_Source:=JHU IACL hc01 manually delineated healthy-control OCT", + *[key + ":=" + value for key, value in metadata.items()]] + path.write_bytes(("\n".join(lines) + "\n\n").encode("ascii") + gzip.compress(data.tobytes(order="C"), mtime=0)) + return {"filename": path.name, "bytes": path.stat().st_size, "sha256": sha256(path)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path, help="Directory containing the original hc01 .vol and .mat") + parser.add_argument("output", type=Path, help="Output directory outside the checkout or in an ignored directory") + parser.add_argument("--write-validity", action="store_true", help="Write an additional validity NRRD outside the ZIP") + args = parser.parse_args() + provenance = json.loads(Path(__file__).with_name("jhu-oct-provenance.json").read_text()) + inputs = [args.source / entry["filename"] for entry in provenance["sources"]] + for path, expected in zip(inputs, provenance["sources"]): + if sha256(path) != expected["sha256"]: + raise ValueError(f"Unexpected source checksum: {path.name}") + native = read_sources(*inputs) + image, labels, validity, spacing, complete = regularize(*native[:5]) + if not np.all(np.isfinite(image)) or image.min() < 0 or image.max() > 1: + raise ValueError("Converted image must contain finite reflectivity display values from zero to one") + if np.any(labels[validity == 0]) or set(np.unique(labels)) != set(range(9)): + raise ValueError("Expected eight labeled layers entirely within valid reflectivity") + image = np.rint(image.astype(np.float64) * 255).astype(np.uint8) + args.output.mkdir(parents=True, exist_ok=True) + metadata = {"Segmentation_MasterRepresentation": "Binary labelmap", "Segmentation_ContainedRepresentationNames": "Binary labelmap|", + "Segmentation_ReferenceImageExtentOffset": "0 0 0", + "OCT_CoveragePolicy": "All eight labels excluded for a column if any expected retinal voxel is invalid; 22 of 25088 columns excluded"} + width, depth, frames = image.shape[::-1] + for index, (name, color) in enumerate(zip(NAMES, COLORS)): + fields = {"ID": f"Segment_{index + 1}", "Name": name, "LabelValue": str(index + 1), + "Color": " ".join(f"{channel / 255:.6f}" for channel in color), "Layer": "0", + "Extent": f"0 {width - 1} 0 {depth - 1} 0 {frames - 1}", "Tags": "|"} + metadata.update({f"Segment{index}_{key}": value for key, value in fields.items()}) + files = [write_nrrd(args.output / "jhu-hc01.nrrd", image, spacing, provenance, + {"OCT_IntensityTransform": "Nested float32 sqrt(sqrt(valid native reflectivity)) before slow-axis interpolation", + "OCT_InvalidPadding": "Missing reflectivity is zero; genuine zero and missing samples are indistinguishable without the optional validity sidecar", + "OCT_RetinalCoverage": "22 of 25088 columns excluded from all labels because expected retina has missing reflectivity", + "OCT_IntensityEncoding": "uint8; normalized intensity = stored value / 255; nearest rounding after slow-axis interpolation"}), + write_nrrd(args.output / "jhu-hc01.seg.nrrd", labels, spacing, provenance, metadata)] + archive = args.output / "jhu-retinal-layers.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_STORED) as bundle: + for entry in files: + info = zipfile.ZipInfo(entry["filename"], (2026, 10, 18, 0, 0, 0)) + info.create_system = 3 # Preserve reference Unix metadata across platforms. + info.external_attr = 0o644 << 16 + bundle.writestr(info, (args.output / entry["filename"]).read_bytes()) + if args.write_validity: + write_nrrd(args.output / "jhu-hc01-validity.nrrd", validity, spacing, provenance, + {"OCT_Validity": "One iff every source with nonzero interpolation weight is valid"}) + report = {"dimensions": list(image.shape[::-1]), "spacing_mm": spacing, "axial_spacing_microns": spacing[1] * 1000, + "intensity_type": "uint8", "normalized_intensity_scale": 255, + "native_invalid_voxels": native[5], "surface_crossing_clamps": native[6], + "complete_retinal_columns": int(complete.sum()), "excluded_retinal_columns": int((~complete).sum()), + "total_retinal_columns": int(complete.size), "files": files, + "archive": {"filename": archive.name, "bytes": archive.stat().st_size, "sha256": sha256(archive)}} + (args.output / "jhu-hc01-conversion.json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/samples/jhu-oct-provenance.json b/scripts/samples/jhu-oct-provenance.json new file mode 100644 index 000000000..873b51183 --- /dev/null +++ b/scripts/samples/jhu-oct-provenance.json @@ -0,0 +1,160 @@ +{ + "dataset": "JHU IACL full retinal layer dataset, healthy control hc01, right eye", + "citation": "Y. He, A. Carass, S.D. Solomon, S. Saidha, P.A. Calabresi, J.L. Prince. Retinal layer parcellation of optical coherence tomography images: Data resource for Multiple Sclerosis and Healthy Controls. Data in Brief 22 (2019), 601-604. DOI: 10.1016/j.dib.2018.12.073.", + "paper_url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC6327073/", + "dataset_url": "https://iacl.ece.jhu.edu/index.php/OCT_Data", + "archive_url": "https://iacl.ece.jhu.edu/~aaron/data/OCT_Manual_Delineations-2018_June_29_b.zip", + "sources": [ + { + "filename": "hc01_spectralis_macula_v1_s1_R.vol", + "archive_member": "OCT_Manual_Delineations-2018_June_29/vol/hc01_spectralis_macula_v1_s1_R.vol", + "bytes": 104921220, + "sha256": "b3cc8ee76292e9c85ba5032ba864c0a3418b831817d458d231e01a453cae3a56" + }, + { + "filename": "hc01_spectralis_macula_v1_s1_R.mat", + "archive_member": "OCT_Manual_Delineations-2018_June_29/delineation/hc01_spectralis_macula_v1_s1_R.mat", + "bytes": 76661, + "sha256": "89b390fe2c0ec59feabefe611fd411640917c96521d21650b22a85e8786fa85b" + } + ], + "annotation_reference": { + "repository": "https://github.com/heyufan1995/oct_preprocess/tree/9248a91ed093711fe2642da491bae6faa58b8e9d", + "reader": "OCTMatTool/Preprocess/Preprocess Utils/readControlPoints.m", + "labeling": "OCTMatTool/Preprocess/Preprocess Utils/convertBoundariesToLabels.m", + "selection": "OCTMatTool/Preprocess/Preprocess.m removes empty one-based boundary slots 3 and 11", + "interpolation": "Sort and deduplicate lateral controls, source unit-parameter natural cubic spline, nearest endpoint extrapolation, and clamp successive boundaries against crossing.", + "rasterization": "Round one-based depth boundaries; each layer occupies upper+1 through lower inclusive.", + "coverage": "All 49 B-scans contain nine manually delineated boundaries. The eight layers are inferred between adjacent source boundaries." + }, + "layers": [ + { + "value": 1, + "name": "RNFL", + "description": "Retinal nerve fiber layer" + }, + { + "value": 2, + "name": "GCL+IPL", + "description": "Ganglion cell and inner plexiform layers" + }, + { + "value": 3, + "name": "INL", + "description": "Inner nuclear layer" + }, + { + "value": 4, + "name": "OPL", + "description": "Outer plexiform layer" + }, + { + "value": 5, + "name": "ONL", + "description": "Outer nuclear layer" + }, + { + "value": 6, + "name": "IS", + "description": "Photoreceptor inner segments" + }, + { + "value": 7, + "name": "OS", + "description": "Photoreceptor outer segments" + }, + { + "value": 8, + "name": "RPE", + "description": "Retinal pigment epithelium" + } + ], + "conversion": { + "command": "python scripts/samples/convert_jhu_oct.py /path/to/extracted/source .tmp/jhu-oct --write-validity", + "dependencies": [ + "numpy", + "scipy" + ], + "source_dimensions_lateral_depth_slow": [ + 1024, + 496, + 49 + ], + "output_dimensions_lateral_depth_slow": [ + 512, + 496, + 49 + ], + "output_spacing_mm": [ + 0.012018056586384773, + 0.003867251332849264, + 0.12810914486180988 + ], + "axial_spacing_microns": 3.867251332849264, + "lateral_sampling": "Retain native lateral indices 0,2,...,1022; native index 1023 is omitted. ScaleX is doubled.", + "slow_sampling": "Linearly resample transformed intensities and unrounded boundaries from per-B-scan physical positions to 49 evenly spaced positions across 6.149238953366875 mm. Native adjacent pitches range from 0.12010203066263614 to 0.13211284885730468 mm.", + "axial_sampling": "Native depth samples and ScaleZ are preserved at 3.867251332849264 microns per voxel.", + "coordinates": "Shared local zero origin, identity direction, and millimetre spacing for image and segmentation. Patient anatomical orientation is not inferred.", + "intensities": "Nested float32 square roots of source reflectivity before slow-axis interpolation, then nearest-even rounding of 255 times the regularized values to uint8. Stored values range from 0 to 255; divide by 255 for normalized display intensity. This is a display transform, not unmodified linear reflectivity.", + "missing_reflectivity": "Non-finite or out-of-range native values are missing. A resampled voxel is valid only when every source with nonzero interpolation weight is valid. Missing output intensity is zero.", + "missing_annotations": "An entire retinal column is excluded from all eight labels when any expected retinal voxel lacks valid intensity. 22 of 25088 output columns are excluded; 25066 have complete retinal coverage.", + "limitations": "Without the optional validity image, genuine zero reflectivity and missing intensity are indistinguishable. Uint8 quantization has a maximum normalized error of 1/510 and can turn small valid intensities into zero; discarded intensity precision cannot be recovered. Zero-filled missing samples contribute to mean and sum projections. Missing labels are zero, so the image/segmentation pair alone cannot distinguish anatomical absence from excluded coverage. Thickness is quantized to the native axial voxel pitch and inherits manual-boundary and slow-axis interpolation uncertainty.", + "bundle": "jhu-retinal-layers.zip contains only jhu-hc01.nrrd and jhu-hc01.seg.nrrd. No layout, session, manifest, or runtime configuration. Optional validity and conversion reports remain outside the ZIP.", + "patient_metadata": "No patient or visit header fields are exported.", + "loading": "Load the ZIP from the Retinal OCT layers sample, disk, or URL parameters. The default seg filename convention selects jhu-hc01.nrrd as the base image and associates jhu-hc01.seg.nrrd as its segmentation. Select the En face view and use the default Coronal projection axis for this non-DICOM OCT; physical calibration is already present in the NRRD." + }, + "terms": { + "article_license": "CC BY 4.0 applies to the article.", + "dataset_statement": "The institutional dataset page and archive README request citation of the paper. Redistribution of the raw data and converted derivatives is permitted.", + "distribution": "The converted image and segmentation are publicly downloadable from the hosted derivative archive recorded in reference_outputs.archive." + }, + "reference_outputs": { + "files": [ + { + "filename": "jhu-hc01.nrrd", + "bytes": 8320884, + "sha256": "6f4fdf19c612b52f604f2468361b5fd4249d36dbed0657db97722bbf716dd729", + "voxel_payload_sha256": "6c3df78b23313b4e0292ee1197b177c8b2aefc500492e973592c40ac825114ce" + }, + { + "filename": "jhu-hc01.seg.nrrd", + "bytes": 98980, + "sha256": "22de13e912b3b3dd47cad11bfef76efdc4b74749e45f47e99846e2e11bc74837", + "voxel_payload_sha256": "ea0f7df26604f3f1193321766123242c4165cfb4fb99a32a335e6b789c977edf" + } + ], + "archive": { + "filename": "jhu-retinal-layers.zip", + "download_url": "https://data.kitware.com/api/v1/file/6ae786c91747c43eb290172f/download", + "file_id": "6ae786c91747c43eb290172f", + "content_type": "application/x-zip-compressed", + "bytes": 8420098, + "sha256": "14e075d6b612db0bc6a606803c5394a4b5757debbd0f34f3f995ad5e969e2130" + }, + "regularization_runtime": { + "python": "3.11.13", + "numpy": "2.4.6", + "scipy": "1.17.1" + }, + "encoding_runtime": { + "python": "3.10.11", + "numpy": "2.2.6" + }, + "regularized_float32_input": { + "download_url": "https://data.kitware.com/api/v1/file/6ae4eeea1747c43eb2901727/download", + "sha256": "682dc9a14fbec482f8e4b0ec2da9e52be5aee40e1080b114437ec225726d7b57" + }, + "checksum_note": "NRRD and ZIP hashes identify the reference outputs, verified against the hosted derivative archive. The uint8 reference was encoded from the existing regularized float32 image without rerunning the unchanged source reading and regularization. Voxel-payload hashes avoid differences in gzip implementation across runtimes.", + "segmentation_label_counts_including_background": [ + 10452711, + 317632, + 409992, + 180509, + 238345, + 335378, + 107797, + 163552, + 237732 + ] + } +} diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index a22b4cfaa..f0fa45f68 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -34,6 +34,7 @@ import { useViewStore } from '@/src/store/views'; const BASE_MODALITY_TYPES = { CT: { priority: 3 }, MR: { priority: 3 }, + OPT: { priority: 3 }, US: { priority: 2 }, DX: { priority: 1 }, } as const; @@ -48,7 +49,8 @@ function findBaseDicom(loadableDataSources: Array) { const baseDicomVolumes = dicoms .map((dicomSource) => { const volumeInfo = dicomStore.volumeInfo[dicomSource.dataID]; - const modality = volumeInfo?.Modality as keyof typeof BASE_MODALITY_TYPES; + const modality = + volumeInfo?.Modality?.trim() as keyof typeof BASE_MODALITY_TYPES; if (modality in BASE_MODALITY_TYPES) return { dicomSource, @@ -63,7 +65,7 @@ function findBaseDicom(loadableDataSources: Array) { { priority: a, volumeInfo: infoA }, { priority: b, volumeInfo: infoB } ) => { - const priorityDiff = a - b; + const priorityDiff = b - a; if (priorityDiff !== 0) return priorityDiff; // same modality, then more slices preferred if (!infoA.NumberOfSlices) return 1; diff --git a/src/assets/samples/Retinal-Layers.png b/src/assets/samples/Retinal-Layers.png new file mode 100644 index 0000000000000000000000000000000000000000..dabc0ba5706976e963f9a852c810a3bfc7897250 GIT binary patch literal 51469 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 P_xgmX?)%2iU-$n2uX4dB literal 0 HcmV?d00001 diff --git a/src/components/AboutBox.vue b/src/components/AboutBox.vue index 151799db5..6764a426e 100644 --- a/src/components/AboutBox.vue +++ b/src/components/AboutBox.vue @@ -168,6 +168,15 @@
Sample data provided by the following sources:
    +
  • + MRA Head and Neck: Test case 98890234 from the "Patient Contributed + Image Repository" (PCIR.org) +
  • PROSTATEx Challenge Data: Geert Litjens, Oscar Debats, Jelle Barentsz, Nico Karssemeijer, and Henkjan Huisman. "ProstateX Challenge data", @@ -179,15 +188,6 @@ >TCIA)
  • -
  • - MRA Head and Neck: Test case 98890234 from the "Patient Contributed - Image Repository" (PCIR.org) -
  • 3D ultrasound of a fetus: Sample data from CC BY 4.0. Ten original DICOM instances selected from each series.
  • +
  • + Retinal layers: OCT data and manual delineations from + Johns Hopkins IACL. Y. He, A. Carass, S. D. Solomon, S. Saidha, P. A. Calabresi, and J. + L. Prince, "Retinal layer parcellation of optical coherence tomography + images: Data resource for Multiple Sclerosis and Healthy Controls", + Data in Brief 22:601–604 (2019), + doi:10.1016/j.dib.2018.12.073. Source data publication: © 2018 The Authors, + CC BY 4.0. Converted from VOL/MAT to NRRD, with lateral subsampling, + interpolation onto a regular B-scan grid, and rasterization of the + manual boundaries into eight tissue labels. +
diff --git a/src/components/BaseImageProperties.vue b/src/components/BaseImageProperties.vue index dc4471295..9cfee04ac 100644 --- a/src/components/BaseImageProperties.vue +++ b/src/components/BaseImageProperties.vue @@ -1,21 +1,24 @@ + + diff --git a/src/components/tools/crosshairs/CrosshairsWidget2D.vue b/src/components/tools/crosshairs/CrosshairsWidget2D.vue index 1849f9d7d..917eb6dde 100644 --- a/src/components/tools/crosshairs/CrosshairsWidget2D.vue +++ b/src/components/tools/crosshairs/CrosshairsWidget2D.vue @@ -1,94 +1,31 @@ - + + diff --git a/src/components/tools/crosshairs/useCrosshairsWidget.ts b/src/components/tools/crosshairs/useCrosshairsWidget.ts new file mode 100644 index 000000000..7cbb73e4a --- /dev/null +++ b/src/components/tools/crosshairs/useCrosshairsWidget.ts @@ -0,0 +1,54 @@ +import { inject, onMounted, onScopeDispose, unref, type MaybeRef } from 'vue'; +import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; +import { VtkViewContext } from '@/src/components/vtk/context'; +import { onVTKEvent } from '@/src/composables/onVTKEvent'; +import { useCrosshairsToolStore } from '@/src/store/tools/crosshairs'; +import type { vtkCrosshairsViewWidget } from '@/src/vtk/CrosshairsWidget'; + +export function useCrosshairsWidget( + viewId: MaybeRef, + preservedAxis?: MaybeRef<0 | 1 | 2 | undefined> +) { + const view = inject(VtkViewContext); + if (!view) throw new Error('No VtkView'); + + const crosshairs = useCrosshairsToolStore(); + const factory = crosshairs.getWidgetFactory(); + const widget = view.widgetManager.addWidget( + factory + ) as vtkCrosshairsViewWidget; + const manipulator = vtkPlaneManipulator.newInstance(); + widget.setManipulator(manipulator); + widget.setVisibility(true); + let dragging = false; + onVTKEvent(widget, 'onStartInteractionEvent', () => { + dragging = true; + }); + onVTKEvent(widget, 'onEndInteractionEvent', () => { + dragging = false; + }); + + // Interaction belongs to the view that picked the shared handle. + const handle = factory.getWidgetState().getHandle(); + onVTKEvent(widget, 'onInteractionEvent', () => { + const origin = handle.getOrigin(); + if (!origin) return; + crosshairs.setPosition(origin, unref(viewId), unref(preservedAxis)); + }); + + onMounted(() => view.widgetManager.renderWidgets()); + onScopeDispose(() => { + widget.setEnabled(false); + view.interactor.cancelAnimation(widget, true); + // A physical drag belongs to this view; a keyboard-only hold may continue + // through remount in another view of the shared widget. + if (dragging) { + crosshairs.setDragging(false); + view.renderWindowView.set({ cursor: 'default' }); + } + view.widgetManager.removeWidget(factory); + manipulator.delete(); + }); + + return manipulator; +} diff --git a/src/components/tools/windowing/WindowLevelControls.vue b/src/components/tools/windowing/WindowLevelControls.vue index 19a370a94..09c4297de 100644 --- a/src/components/tools/windowing/WindowLevelControls.vue +++ b/src/components/tools/windowing/WindowLevelControls.vue @@ -10,8 +10,11 @@ import { WLAutoRanges, WLPresetsCT } from '@/src/constants'; import { getWindowLevels, useDICOMStore } from '@/src/store/datasets-dicom'; import { isDicomImage } from '@/src/utils/dataSelection'; import { storeToRefs } from 'pinia'; +import { useEnFaceViewStore } from '@/src/enface/store'; +import ReasonedAction from '@/src/components/ReasonedAction.vue'; export default defineComponent({ + components: { ReasonedAction }, props: { viewId: String, imageId: String, @@ -22,9 +25,20 @@ export default defineComponent({ const windowingStore = useWindowingStore(); const viewStore = useViewStore(); const dicomStore = useDICOMStore(); + const enFaceStore = useEnFaceViewStore(); const { activeView } = storeToRefs(viewStore); const targetViewID = computed(() => props.viewId ?? activeView.value); const targetImageID = computed(() => props.imageId ?? currentImageID.value); + const isSumProjection = computed(() => { + const viewID = targetViewID.value; + const imageID = targetImageID.value; + return ( + !!viewID && + !!imageID && + viewStore.getView(viewID)?.type === 'EnFace' && + enFaceStore.settingsFor(viewID, imageID).method === 'sum' + ); + }); function parseLabel(text: string) { return text.replace(/([A-Z])/g, ' $1').trim(); @@ -65,44 +79,59 @@ export default defineComponent({ const wlWidth = computed(() => wlConfig.value.width ?? 1); const wlLevel = computed(() => wlConfig.value.level ?? 0.5); - const widthFocused = ref(false); - const levelFocused = ref(false); - - const formatForDisplay = (value: number) => Number(value.toPrecision(4)); - - const displayWidth = computed({ - get: () => - widthFocused.value ? wlWidth.value : formatForDisplay(wlWidth.value), - set: (value: number) => { - const imageID = targetImageID.value; - const viewID = targetViewID.value; - if (!imageID || !viewID || !Number.isFinite(value)) return; - windowingStore.updateConfig( - viewID, - imageID, - { width: value, level: wlLevel.value }, - true - ); - emit('change'); - }, - }); - - const displayLevel = computed({ - get: () => - levelFocused.value ? wlLevel.value : formatForDisplay(wlLevel.value), - set: (value: number) => { - const imageID = targetImageID.value; - const viewID = targetViewID.value; - if (!imageID || !viewID || !Number.isFinite(value)) return; - windowingStore.updateConfig( - viewID, - imageID, - { width: wlWidth.value, level: value }, - true - ); - emit('change'); - }, - }); + const manualField = (field: 'width' | 'level') => { + const currentValue = computed(() => + field === 'width' ? wlWidth.value : wlLevel.value + ); + const editing = ref(false); + // Preserve incomplete decimal and exponent entries until the field loses focus. + const draft = ref(''); + const value = computed({ + get: () => + editing.value + ? draft.value + : String(Number(currentValue.value.toPrecision(4))), + set: (text: string) => { + draft.value = text; + const parsed = Number(text); + const imageID = targetImageID.value; + const viewID = targetViewID.value; + if ( + !imageID || + !viewID || + !text.trim() || + !Number.isFinite(parsed) || + (field === 'width' && parsed <= 0) + ) + return; + windowingStore.updateConfig( + viewID, + imageID, + { + width: wlWidth.value, + level: wlLevel.value, + [field]: parsed, + }, + true + ); + emit('change'); + }, + }); + return { + value, + begin: () => { + draft.value = String(currentValue.value); + editing.value = true; + }, + end: () => { + editing.value = false; + }, + }; + }; + const widthField = manualField('width'); + const levelField = manualField('level'); + const displayWidth = widthField.value; + const displayLevel = levelField.value; const wlOptions = computed({ get() { @@ -121,6 +150,7 @@ export default defineComponent({ } if (typeof selection === 'object') { + if (isSumProjection.value) return; windowingStore.updateConfig( viewID, imageID, @@ -144,6 +174,14 @@ export default defineComponent({ }, }); + function resetContrast() { + const viewID = targetViewID.value; + const imageID = targetImageID.value; + if (!viewID || !imageID) return; + windowingStore.resetContrast(viewID, imageID); + emit('change'); + } + // --- Tag WL Options --- // const tags = computed(() => { if (targetImageID.value && isDicomImage(targetImageID.value)) { @@ -155,15 +193,29 @@ export default defineComponent({ return { parseLabel, + resetContrast, + resetContrastReason: computed(() => { + if (!targetViewID.value) return 'Select a view to reset contrast.'; + if (!targetImageID.value) + return 'Load an image in this view to reset contrast.'; + return ''; + }), + targetViewID, + targetImageID, wlOptions, WLPresetsCT, showCtPresets, tags, + sourcePresetReason: computed(() => + isSumProjection.value + ? 'Source intensity presets do not apply to Sum projections. Use Auto Window/Level or Manual instead.' + : '' + ), WLAutoRanges, + widthField, + levelField, displayWidth, displayLevel, - widthFocused, - levelFocused, }; }, }); @@ -173,7 +225,12 @@ export default defineComponent({ - + - + Auto Window/Level
@@ -218,8 +282,9 @@ export default defineComponent({
+
+ + + Reset contrast + + +
diff --git a/src/components/vtk/VtkRenderWindowParent.vue b/src/components/vtk/VtkRenderWindowParent.vue index ea64ef36d..5063b20e2 100644 --- a/src/components/vtk/VtkRenderWindowParent.vue +++ b/src/components/vtk/VtkRenderWindowParent.vue @@ -1,5 +1,6 @@ diff --git a/src/components/vtk/VtkSlabProjectionRepresentation.vue b/src/components/vtk/VtkSlabProjectionRepresentation.vue new file mode 100644 index 000000000..5bd96d18b --- /dev/null +++ b/src/components/vtk/VtkSlabProjectionRepresentation.vue @@ -0,0 +1,119 @@ + + + diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index aa8a08e7c..2d404de5a 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -22,12 +22,17 @@ import { Maybe } from '@/src/types'; import { VtkViewApi } from '@/src/types/vtk-types'; import { VtkViewContext } from '@/src/components/vtk/context'; import { useViewCameraStore } from '@/src/store/view-configs/camera'; +import type { ImageMetadata } from '@/src/types/image'; +import { getLPSDirections } from '@/src/utils/lps'; +import { vec3 } from 'gl-matrix'; type Props = { viewId: string; imageId: Maybe; viewDirection: LPSAxisDir; viewUp: LPSAxisDir; + renderGeometry?: { metadata: ImageMetadata | null; fitKey: string }; + zoomOnScroll?: boolean; }; const props = defineProps(); @@ -42,7 +47,12 @@ const vtkContainerRef = ref(); const viewCameraStore = useViewCameraStore(); const { disableCameraAutoReset } = storeToRefs(viewCameraStore); -const { metadata: imageMetadata } = useImage(imageID); +const { metadata: sourceMetadata } = useImage(imageID); +const imageMetadata = computed(() => + props.renderGeometry === undefined + ? sourceMetadata.value + : props.renderGeometry.metadata +); // use a detached scope so that actors can be removed from // the renderer before the renderer is deleted. @@ -50,7 +60,7 @@ const scope = effectScope(true); const view = scope.run(() => useVtkView(vtkContainerRef, { separateImageLayer: true, - clippingBounds: computed(() => imageMetadata.value.worldBounds), + clippingBounds: computed(() => imageMetadata.value?.worldBounds), }) )!; @@ -87,14 +97,10 @@ const autoFit = useAutoFitState( ); function autoFitImage() { - if (!autoFit.autoFit.value) return; + const metadata = imageMetadata.value; + if (!autoFit.autoFit.value || !metadata) return; autoFit.withPaused(() => { - resizeToFitImage( - view, - imageMetadata.value, - viewDirection.value, - viewUp.value - ); + resizeToFitImage(view, metadata, viewDirection.value, viewUp.value); }); } @@ -104,29 +110,79 @@ useResizeObserver(vtkContainerRef, () => { }); function resetCamera() { + const metadata = imageMetadata.value; + if (!metadata) return; autoFit.autoFit.value = true; autoFit.withPaused(() => { - resetCameraToImage( - view, - imageMetadata.value, - viewDirection.value, - viewUp.value - ); + resetCameraToImage(view, metadata, viewDirection.value, viewUp.value); autoFitImage(); }); + viewCameraStore.markCameraAsInitialized(viewID.value, imageID.value); } +let previousGeometry: { + viewId: string; + imageId: Maybe; + key?: string; + direction: LPSAxisDir; + up: LPSAxisDir; +}; +const isPreviousImage = (viewId: string, imageId: Maybe) => + previousGeometry?.viewId === viewId && previousGeometry.imageId === imageId; +const projectionOrientationChanged = (direction: LPSAxisDir, up: LPSAxisDir) => + !!props.renderGeometry && + (previousGeometry.direction !== direction || previousGeometry.up !== up); watchImmediate( - [viewID, imageID, disableCameraAutoReset], - ([viewID_, imageID_, noAutoReset]) => { + [ + viewID, + imageID, + disableCameraAutoReset, + imageMetadata, + () => props.renderGeometry?.fitKey, + viewDirection, + viewUp, + ], + ([viewID_, imageID_, noAutoReset, metadata, geometryKey, direction, up]) => { + if (!metadata) return; + const sameImage = isPreviousImage(viewID_, imageID_); + const geometryChanged = sameImage && previousGeometry.key !== geometryKey; if ( imageID_ && - !viewCameraStore.isCameraInitialized(viewID_, imageID_) && + (!viewCameraStore.isCameraInitialized(viewID_, imageID_) || + geometryChanged) && !noAutoReset ) { resetCamera(); - viewCameraStore.markCameraAsInitialized(viewID_, imageID_); + } else if ( + noAutoReset && + sameImage && + projectionOrientationChanged(direction, up) + ) { + // A new projection plane still needs a facing camera; keep pan and zoom. + autoFit.withPaused(() => { + const camera = view.renderer.getActiveCamera(); + const focalPoint = [...camera.getFocalPoint()]; + const directions = getLPSDirections(metadata.orientation); + const normal = vec3.normalize([0, 0, 0], directions[direction]); + camera.setPositionAndFocalPoint( + focalPoint.map( + (value, axis) => value - camera.getDistance() * normal[axis] + ), + focalPoint + ); + const vertical = directions[up]; + camera.setViewUp(vertical[0], vertical[1], vertical[2]); + view.renderer.resetCameraClippingRange(metadata.worldBounds); + view.requestRender(); + }); } + previousGeometry = { + viewId: viewID_, + imageId: imageID_, + key: geometryKey, + direction, + up, + }; } ); @@ -137,11 +193,13 @@ watchImmediate( usePersistCameraConfig(viewID, imageID, view.renderer.getActiveCamera()); watchImmediate([imageMetadata, disableCameraAutoReset], () => { + const metadata = imageMetadata.value; + if (!metadata) return; if ( viewCameraStore.isCameraInitialized(viewID.value, imageID.value) || disableCameraAutoReset.value ) { - view.renderer.resetCameraClippingRange(imageMetadata.value.worldBounds); + view.renderer.resetCameraClippingRange(metadata.worldBounds); } }); @@ -162,6 +220,17 @@ function onPointerDown() { function onPointerUp() { autoFit.pause(); } + +function onWheel(event: WheelEvent) { + if ( + props.zoomOnScroll && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + autoFit.autoFit.value = false; + } +}
@@ -126,6 +108,7 @@ const annotationTools = [ { value: Tools.Polygon, icon: 'mdi-pentagon-outline', name: 'Polygon' }, { value: Tools.Ruler, icon: 'mdi-ruler', name: 'Ruler' }, ] as const; +const buttonToolGroups = [navigationTools, annotationTools]; export default defineComponent({ components: { @@ -220,8 +203,7 @@ export default defineComponent({ toolUnavailableReason, Tools, nameToShortcut, - navigationTools, - annotationTools, + buttonToolGroups, }; }, }); diff --git a/src/components/tools/crosshairs/CrosshairsWidget.vue b/src/components/tools/crosshairs/CrosshairsWidget.vue index c865b8b38..120e020a0 100644 --- a/src/components/tools/crosshairs/CrosshairsWidget.vue +++ b/src/components/tools/crosshairs/CrosshairsWidget.vue @@ -7,10 +7,9 @@ const props = defineProps<{ viewId: string; planeOrigin: Vector3; planeNormal: Vector3; - preservedAxis?: 0 | 1 | 2; }>(); -const { viewId, preservedAxis } = toRefs(props); -const manipulator = useCrosshairsWidget(viewId, preservedAxis); +const { viewId } = toRefs(props); +const manipulator = useCrosshairsWidget(viewId); watchEffect(() => { manipulator.setUserOrigin(props.planeOrigin); manipulator.setUserNormal(props.planeNormal); diff --git a/src/components/tools/crosshairs/useCrosshairsWidget.ts b/src/components/tools/crosshairs/useCrosshairsWidget.ts index 7cbb73e4a..ecf399266 100644 --- a/src/components/tools/crosshairs/useCrosshairsWidget.ts +++ b/src/components/tools/crosshairs/useCrosshairsWidget.ts @@ -5,10 +5,7 @@ import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { useCrosshairsToolStore } from '@/src/store/tools/crosshairs'; import type { vtkCrosshairsViewWidget } from '@/src/vtk/CrosshairsWidget'; -export function useCrosshairsWidget( - viewId: MaybeRef, - preservedAxis?: MaybeRef<0 | 1 | 2 | undefined> -) { +export function useCrosshairsWidget(viewId: MaybeRef) { const view = inject(VtkViewContext); if (!view) throw new Error('No VtkView'); @@ -33,7 +30,7 @@ export function useCrosshairsWidget( onVTKEvent(widget, 'onInteractionEvent', () => { const origin = handle.getOrigin(); if (!origin) return; - crosshairs.setPosition(origin, unref(viewId), unref(preservedAxis)); + crosshairs.setPosition(origin, unref(viewId)); }); onMounted(() => view.widgetManager.renderWidgets()); diff --git a/src/components/tools/windowing/WindowLevelControls.vue b/src/components/tools/windowing/WindowLevelControls.vue index 09c4297de..dc1669c95 100644 --- a/src/components/tools/windowing/WindowLevelControls.vue +++ b/src/components/tools/windowing/WindowLevelControls.vue @@ -150,7 +150,6 @@ export default defineComponent({ } if (typeof selection === 'object') { - if (isSumProjection.value) return; windowingStore.updateConfig( viewID, imageID, @@ -200,8 +199,6 @@ export default defineComponent({ return 'Load an image in this view to reset contrast.'; return ''; }), - targetViewID, - targetImageID, wlOptions, WLPresetsCT, showCtPresets, diff --git a/src/components/vtk/VtkSlabProjectionRepresentation.vue b/src/components/vtk/VtkSlabProjectionRepresentation.vue index 5bd96d18b..444411c39 100644 --- a/src/components/vtk/VtkSlabProjectionRepresentation.vue +++ b/src/components/vtk/VtkSlabProjectionRepresentation.vue @@ -2,7 +2,6 @@ import { computed, inject, onScopeDispose, watchEffect } from 'vue'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; -import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; import { SlabTypes } from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper/Constants'; import type { Vector3 } from '@kitware/vtk.js/types'; import { vec3 } from 'gl-matrix'; @@ -40,10 +39,6 @@ const representation = useResliceRepresentation( ); const { actor, mapper, property } = representation; const slicePlane = vtkPlane.newInstance(); -const opacity = vtkPiecewiseFunction.newInstance(); -opacity.addPoint(0, 1); -opacity.addPoint(1, 1); -property.setPiecewiseFunction(0, opacity); useBaseSliceColoring( property, computed(() => props.window.preset), @@ -81,7 +76,6 @@ watchEffect(() => { }); onScopeDispose(() => { slicePlane.delete(); - opacity.delete(); }); const corners = useVTKMultiWorldToSVG( computed(() => { diff --git a/src/composables/useViewWindowingRanges.ts b/src/composables/useViewWindowingRanges.ts index 02b58cfbd..982b700f0 100644 --- a/src/composables/useViewWindowingRanges.ts +++ b/src/composables/useViewWindowingRanges.ts @@ -1,9 +1,7 @@ import { computed, unref, watch, type MaybeRef } from 'vue'; import type { Maybe } from '@/src/types'; -import { - useWindowingStore, - type WindowingAutoRanges, -} from '@/src/store/view-configs/windowing'; +import type { WindowingAutoRanges } from '@/src/constants'; +import { useWindowingStore } from '@/src/store/view-configs/windowing'; export function useViewWindowingRanges( viewId: MaybeRef, @@ -21,7 +19,7 @@ export function useViewWindowingRanges( }, { immediate: true } ); - const config = computed(() => { + return computed(() => { const imageID = unref(imageId); const shared = imageID ? windowing.getConfig(unref(viewId), imageID) @@ -29,10 +27,8 @@ export function useViewWindowingRanges( const width = shared?.width ?? 1; return { ...shared, - userTriggered: shared?.userTriggered ?? false, width: Number.isFinite(width) && width > 0 ? width : 1, level: shared?.level ?? 0.5, }; }); - return config; } diff --git a/src/config.ts b/src/config.ts index 8dc06c291..0730fcfc3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ import USFetusThumbnail from '@/src/assets/samples/3DUS-Fetus.jpg'; import USCineThumbnail from '@/src/assets/samples/US-Cine.jpg'; import { PET_CT_URLS } from './samples/petCT'; import PETCTChestThumbnail from '@/src/assets/samples/PET-CT-Chest.png'; -import RetinalLayersThumbnail from '@/src/assets/samples/Retinal-Layers.png'; +import RetinalLayersThumbnail from '@/src/assets/samples/Retinal-Layers.jpg'; import type { LayoutConfig } from './utils/layoutParsing'; import type { ViewInfoInit } from './types/views'; import { SampleDataset } from './types'; @@ -69,10 +69,9 @@ export const getAvailableViews = () => { orientation: 'Axial', }, }, + { name: 'En face', type: 'EnFace', dataID: null, options: {} }, ]; - list.push({ name: 'En face', type: 'EnFace', dataID: null, options: {} }); - const byName = list.reduce( (acc, view) => ({ ...acc, [view.name]: view }), {} as Record diff --git a/src/constants.ts b/src/constants.ts index 5b2c9ef77..da22b391f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -153,6 +153,11 @@ export const WLAutoRanges = { HighContrast: 5.0, }; +export type WindowingAutoRanges = Record< + keyof typeof WLAutoRanges, + readonly [number, number] +>; + export const WL_AUTO_DEFAULT = 'FullRange'; export const WL_HIST_BINS = 512; diff --git a/src/core/slab/__tests__/geometry.spec.ts b/src/core/slab/__tests__/geometry.spec.ts index 23b65d7b6..0abb523e0 100644 --- a/src/core/slab/__tests__/geometry.spec.ts +++ b/src/core/slab/__tests__/geometry.spec.ts @@ -76,7 +76,7 @@ describe('slab source-world geometry', () => { expect(geometry.metadata).toEqual({ name: '', ...ordinaryMetadata }); expect( get2DOrientationForSourceAxis(axis, geometry.metadata.lpsOrientation) - ).toEqual({ name, ...get2DViewingVectors(name) }); + ).toEqual(get2DViewingVectors(name)); } image.delete(); }); diff --git a/src/core/slab/geometry.ts b/src/core/slab/geometry.ts index ab7a1f024..b2e844b3d 100644 --- a/src/core/slab/geometry.ts +++ b/src/core/slab/geometry.ts @@ -21,10 +21,8 @@ export function deriveSlabGeometry( spec: SlabSpecification ) { const axes = [0, 1, 2].filter((axis) => axis !== spec.axis); - const dimensions = image.getDimensions(); const extent = image.getExtent(); const minimum = [extent[0], extent[2], extent[4]]; - const sourceSpacing = image.getSpacing(); const midpoint = minimum[spec.axis] + (spec.first + spec.last) / 2; const planeIndex = [...minimum] as Vector3; planeIndex[spec.axis] = midpoint; @@ -48,7 +46,6 @@ export function deriveSlabGeometry( planeOrigin[component] - (pixelToWorld[component] + pixelToWorld[4 + component]) / 2; } - const orientation = image.getDirection(); const metadata: ImageMetadata = { name: '', ...metadataFromVtkImageData(image), @@ -62,11 +59,11 @@ export function deriveSlabGeometry( slabThickness: slabThickness(image, spec), fitKey: [ spec.axis, - ...dimensions, + ...metadata.dimensions, ...extent, - ...sourceSpacing, - ...image.getOrigin(), - ...orientation, + ...metadata.spacing, + ...metadata.origin, + ...metadata.orientation, ].join(','), }; } diff --git a/src/core/slab/types.ts b/src/core/slab/types.ts index fb1b1a62a..1f0c94682 100644 --- a/src/core/slab/types.ts +++ b/src/core/slab/types.ts @@ -1,5 +1,6 @@ +export const SLAB_METHODS = ['mean', 'max', 'sum'] as const; export type SlabAxis = 0 | 1 | 2; -export type SlabMethod = 'mean' | 'max' | 'sum'; +export type SlabMethod = (typeof SLAB_METHODS)[number]; export type SlabSpecification = { axis: SlabAxis; first: number; diff --git a/src/core/slab/windowRanges.ts b/src/core/slab/windowRanges.ts index 17704a64e..555e397b8 100644 --- a/src/core/slab/windowRanges.ts +++ b/src/core/slab/windowRanges.ts @@ -1,10 +1,9 @@ import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import { WLAutoRanges } from '@/src/constants'; +import { WLAutoRanges, type WindowingAutoRanges } from '@/src/constants'; import type { SlabMethod, SlabSpecification } from './types'; import { slabThickness } from './geometry'; -type WindowRanges = Record; -type ProjectionRanges = Record; +type ProjectionRanges = Record; type CachedRanges = { revision: string; slabs: Map; @@ -102,7 +101,7 @@ function sampleProjection(image: vtkImageData, spec: SlabSpecification) { function windowRanges( samples: number[], complete: [number, number] -): WindowRanges { +): WindowingAutoRanges { const sorted = samples.filter(Number.isFinite).sort((a, b) => a - b); const percentile = ( percentage: number, diff --git a/src/core/streaming/dicom/octMetadata.ts b/src/core/streaming/dicom/octMetadata.ts index 09531c560..f1153b755 100644 --- a/src/core/streaming/dicom/octMetadata.ts +++ b/src/core/streaming/dicom/octMetadata.ts @@ -1,22 +1,17 @@ import dicomParser, { type DataSet } from 'dicom-parser'; import { inflateSync } from 'fflate'; import { vec3 } from 'gl-matrix'; +import { areEquals } from '@kitware/vtk.js/Common/Core/Math'; import { Tags, toDicomParserKey } from '@/src/core/dicomTags'; import { isOCTMetadata, OCT_GEOMETRY_ERROR, OCT_SCAN_PATTERN, } from '@/src/core/streaming/dicom/octMetadataTags'; +import type { Maybe } from '@/src/types'; -const close = (a: number, b: number, tolerance = 1e-5) => +const close = (a: number, b: number, tolerance: number) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tolerance; -const coordinatesMatch = ( - a: ArrayLike, - b: ArrayLike, - tolerance = 1e-5 -) => - a.length === b.length && - Array.from(a).every((n, i) => close(n, b[i], tolerance)); const finiteVector = ( value: number[] | null | undefined, length: number @@ -39,7 +34,7 @@ function imageDirection(basis: number[] | null | undefined) { } /** Validate equally spaced slices along the positive image normal. */ -function frameGridError(positions: number[][], direction?: number[]) { +function frameGridError(positions: number[][], direction: number[] | null) { if (positions.some((p) => p.length !== 3 || !p.every(Number.isFinite))) return 'DICOM slice positions are invalid; spatial reconstruction is required.'; if (positions.length < 2) return null; @@ -53,7 +48,7 @@ function frameGridError(positions: number[][], direction?: number[]) { return 'Repeated DICOM slice locations need spatial reconstruction.'; if ( direction && - !coordinatesMatch( + !areEquals( step, direction.slice(6).map((n) => n * distance), tolerance @@ -63,7 +58,7 @@ function frameGridError(positions: number[][], direction?: number[]) { if ( positions.some( (position, i) => - !coordinatesMatch( + !areEquals( position, positions[0].map((n, axis) => n + i * step[axis]), tolerance @@ -99,6 +94,10 @@ const sequenceTags = new Set([ const firstItem = (data: DataSet | undefined, tag: string) => data?.elements[tag]?.items?.[0]?.dataSet; +const sequenceItems = (data: DataSet, tag: string) => + data.elements[tag]?.items?.flatMap((item) => + item.dataSet ? [item.dataSet] : [] + ) ?? []; const stringValue = (data: DataSet | undefined, tag: string) => data?.string(tag)?.trim() || undefined; const vector = (value: string | undefined) => value?.split('\\').map(Number); @@ -113,10 +112,7 @@ function frameAttribute( function frameGeometry(data: DataSet) { const shared = firstItem(data, SHARED); - const frames = - data.elements[PER_FRAME]?.items?.flatMap((item) => - item.dataSet ? [item.dataSet] : [] - ) ?? []; + const frames = sequenceItems(data, PER_FRAME); // Shared groups take precedence over the first frame, then the top level. const attribute = (sequence: string, tag: string) => frameAttribute(shared, sequence, tag) ?? @@ -147,11 +143,7 @@ function frameAttributeMismatch( return geometry.frames.some((frame) => { const value = vector(frameAttribute(frame, sequence, attribute)); return value - ? !coordinatesMatch( - value, - expected, - sequence === ORIENTATION ? 1e-4 : 1e-5 - ) + ? !areEquals(value, expected, sequence === ORIENTATION ? 1e-4 : 1e-5) : false; }); } @@ -169,7 +161,7 @@ function positionError(geometry: ReturnType) { !positiveNumber(Number(geometry.sliceSpacing)) ) return 'OCT slice spacing is unavailable; spatial reconstruction is required.'; - return frameGridError(positions, direction ?? undefined); + return frameGridError(positions, direction); } function geometryError(geometry: ReturnType) { @@ -210,16 +202,11 @@ function geometryError(geometry: ReturnType) { } function scanPattern(data: DataSet) { - const acquisitions = - data.elements[ACQUISITION_PARAMETERS]?.items?.flatMap((item) => - item.dataSet ? [item.dataSet] : [] - ) ?? []; - const patterns = [data, ...acquisitions].flatMap( - (dataset) => - dataset.elements[SCAN_PATTERN]?.items?.flatMap((item) => { - const code = stringValue(item.dataSet, 'x00080100'); - return code ? [code] : []; - }) ?? [] + const acquisitions = sequenceItems(data, ACQUISITION_PARAMETERS); + const patterns = [data, ...acquisitions].flatMap((dataset) => + sequenceItems(dataset, SCAN_PATTERN).flatMap( + (item) => stringValue(item, 'x00080100') ?? [] + ) ); return acquisitions.length > 1 || new Set(patterns).size > 1 ? 'multiple' @@ -228,7 +215,7 @@ function scanPattern(data: DataSet) { function promoteOCTGeometry(data: DataSet, tags: Map) { const geometry = frameGeometry(data); - const set = (tag: string, value: string | undefined) => { + const set = (tag: string, value: Maybe) => { if (value) tags.set(tag, value); }; set(Tags.PixelSpacing, geometry.spacing); @@ -238,7 +225,7 @@ function promoteOCTGeometry(data: DataSet, tags: Map) { ); set(OCT_SCAN_PATTERN, scanPattern(data)); const reason = geometryError(geometry); - set(OCT_GEOMETRY_ERROR, reason ?? undefined); + set(OCT_GEOMETRY_ERROR, reason); if (reason) return; set(Tags.ImageOrientationPatient, geometry.orientation); set(Tags.ImagePositionPatient, geometry.origin); diff --git a/src/enface/EnFaceControlPanel.vue b/src/enface/EnFaceControlPanel.vue index 9cd534927..72b3bbd71 100644 --- a/src/enface/EnFaceControlPanel.vue +++ b/src/enface/EnFaceControlPanel.vue @@ -33,25 +33,19 @@ const controlState = computed(() => { projectionReason: reason, } satisfies EnFaceViewControlState; }); -function resetContrast() { - if (props.dataId && !controlState.value.projectionReason) - windowing.resetContrast(props.viewId, props.dataId); -} function patch(patchSettings: Partial) { - if (!props.dataId) return; + const { viewId, dataId } = props; + if (!dataId) return; const methodChanged = patchSettings.method !== undefined && patchSettings.method !== settings.value.method; - Object.assign( - enFaceStore.configFor(props.viewId, props.dataId), - patchSettings - ); + Object.assign(enFaceStore.configFor(viewId, dataId), patchSettings); if ( methodChanged && - props.dataId && - !windowing.getConfig(props.viewId, props.dataId).useAuto + !controlState.value.projectionReason && + !windowing.getConfig(viewId, dataId).useAuto ) - resetContrast(); + windowing.resetContrast(viewId, dataId); } diff --git a/src/enface/EnFaceControls.vue b/src/enface/EnFaceControls.vue index 6fa12b267..891eea578 100644 --- a/src/enface/EnFaceControls.vue +++ b/src/enface/EnFaceControls.vue @@ -2,6 +2,7 @@ import { ref, watch } from 'vue'; import ReasonedAction from '@/src/components/ReasonedAction.vue'; import { rangeThumbLabels } from '@/src/utils/rangeThumbLabels'; +import { SLAB_METHODS } from '@/src/core/slab/types'; import type { EnFaceViewConfig, EnFaceViewControlState } from './store'; const props = defineProps< @@ -39,8 +40,8 @@ function applyKeyboardSlab() { if (slabDraft.value) updateSlab(slabDraft.value); } function updateMethod(method: string) { - if (method === 'mean' || method === 'max' || method === 'sum') - emit('patch', { method }); + const value = SLAB_METHODS.find((candidate) => candidate === method); + if (value) emit('patch', { method: value }); } function updateAxis(axis: number) { if (axis === 0 || axis === 1 || axis === 2) diff --git a/src/enface/EnFaceViewer.vue b/src/enface/EnFaceViewer.vue index d73f96b1c..ce9af1ab3 100644 --- a/src/enface/EnFaceViewer.vue +++ b/src/enface/EnFaceViewer.vue @@ -145,7 +145,6 @@ watch( :view-id="viewId" :plane-origin="geometry.planeOrigin" :plane-normal="geometry.planeNormal" - :preserved-axis="specification.axis" /> diff --git a/src/enface/store.ts b/src/enface/store.ts index 734423d83..61be4e691 100644 --- a/src/enface/store.ts +++ b/src/enface/store.ts @@ -6,7 +6,7 @@ import { getDoubleKeyRecord, type DoubleKeyRecord, } from '@/src/utils/doubleKeyRecord'; -import type { StateFile, ViewConfig } from '@/src/io/state-file/schema'; +import type { ViewConfig } from '@/src/io/state-file/schema'; import type { SlabAxis, SlabMethod } from '@/src/core/slab/types'; import type { Maybe } from '@/src/types'; @@ -77,9 +77,7 @@ export const useEnFaceViewStore = defineStore('enface-view', () => { } } - function serialize(stateFile: StateFile) { - createViewConfigSerializer(configs, 'enFace')(stateFile); - } + const serialize = createViewConfigSerializer(configs, 'enFace'); function deserialize(viewID: string, config: Record) { Object.entries(config).forEach(([dataID, viewConfig]) => { diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index f437cfae0..839d934f5 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -39,6 +39,7 @@ import type { BlendConfig, } from '@/src/types/views'; import { WLAutoRanges } from '@/src/constants'; +import { SLAB_METHODS } from '@/src/core/slab/types'; import { type Layout, type LayoutDirection, @@ -283,7 +284,7 @@ const CinePlaybackViewConfig = z.object({ const EnFaceViewConfig = z.object({ axis: z.union([z.literal(0), z.literal(1), z.literal(2)]), - method: z.enum(['mean', 'max', 'sum']), + method: z.enum(SLAB_METHODS), depthStart: z.number().int().nonnegative(), depthEnd: z.number().int().nonnegative().nullable(), }) satisfies z.ZodType; diff --git a/src/store/tools/__tests__/crosshairs.spec.ts b/src/store/tools/__tests__/crosshairs.spec.ts index e3da2314d..7d9968f0c 100644 --- a/src/store/tools/__tests__/crosshairs.spec.ts +++ b/src/store/tools/__tests__/crosshairs.spec.ts @@ -104,11 +104,7 @@ describe('crosshairs tool store', () => { useViewStore().setActiveView(coronalB); useViewStore().setLayoutFromGrid([1, 1]); - useCrosshairsToolStore().setPosition( - [2, 8, 17], - projection, - preservedAxis - ); + useCrosshairsToolStore().setPosition([2, 8, 17], projection); expect(sliceOf(sagittalA, imageA)).toBe(preservedAxis === 0 ? 3 : 2); expect(sliceOf(coronalA, imageA)).toBe(preservedAxis === 1 ? 6 : 8); @@ -118,13 +114,6 @@ describe('crosshairs tool store', () => { } ); - it('requires an explicit preserved axis when navigating from a projection', () => { - const { projection } = projectionWithPeer(); - useCrosshairsToolStore().setPosition([2, 8, 17], projection); - expect(sliceOf(axialA, imageA)).toBe(5); - expect(sliceOf(sagittalA, imageA)).toBe(3); - }); - it('slices every view showing the image the crosshair was moved in', () => { useCrosshairsToolStore().setPosition([2, 8, 17], axialA); diff --git a/src/store/tools/crosshairs.ts b/src/store/tools/crosshairs.ts index 9c754da81..0531322fe 100644 --- a/src/store/tools/crosshairs.ts +++ b/src/store/tools/crosshairs.ts @@ -16,6 +16,7 @@ import { IMAGE_BOX_INFLATION } from '@/src/constants'; import { clampValue } from '@/src/utils'; import type { ImageMetadata } from '@/src/types/image'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; +import { useEnFaceViewStore } from '@/src/enface/store'; import type { Maybe } from '@/src/types'; export function getEnFaceCrosshairsUnavailableReason(imageID: Maybe) { @@ -68,25 +69,24 @@ export const useCrosshairsToolStore = defineStore('crosshairs', () => { * * All registered views of the image are sliced, not just those in the * current layout, so views hidden by a layout switch come back consistent. - * A preserved source index axis leaves its peer slices unchanged: a - * projection pick selects an in-plane location without choosing depth. + * An en face pick leaves the peer slices along its projection axis + * unchanged: it selects an in-plane location without choosing depth. */ - function setPosition( - worldPosition: Vector3, - viewId: string, - preservedAxis?: 0 | 1 | 2 - ) { + function setPosition(worldPosition: Vector3, viewId: string) { const view = viewStore.getView(viewId); const host = view && computeEffectiveView(view, view.dataID); if (host?.kind !== 'volume2D' && host?.kind !== 'enface') return; if ( host.kind === 'enface' && - (preservedAxis == null || - getEnFaceCrosshairsUnavailableReason(host.renderDataID)) + getEnFaceCrosshairsUnavailableReason(host.renderDataID) ) return; const imageID = host.renderDataID; + const preservedAxis = + host.kind === 'enface' + ? useEnFaceViewStore().settingsFor(viewId, imageID).axis + : undefined; const metadata = getImageMetadata(imageID); const indexPosition = clampToImage(worldPosition, metadata); diff --git a/src/store/tools/index.ts b/src/store/tools/index.ts index 1d17ff0b5..a6c2a5372 100644 --- a/src/store/tools/index.ts +++ b/src/store/tools/index.ts @@ -29,15 +29,16 @@ const CINE_DISALLOWED_TOOLS = new Set([ Tools.Crosshairs, ]); +const EN_FACE_TOOLS = new Set([ + Tools.Select, + Tools.WindowLevel, + Tools.Pan, + Tools.Zoom, + Tools.Crosshairs, +]); + export function isToolAllowedFor(tool: Tools, effective: EffectiveView | null) { - if (effective?.viewInfo.type === 'EnFace') - return [ - Tools.Select, - Tools.WindowLevel, - Tools.Pan, - Tools.Zoom, - Tools.Crosshairs, - ].includes(tool); + if (effective?.viewInfo.type === 'EnFace') return EN_FACE_TOOLS.has(tool); if (effective?.kind === 'cine' && CINE_DISALLOWED_TOOLS.has(tool)) return false; return true; @@ -52,16 +53,11 @@ export function getToolUnavailableReason( ? 'This tool is unavailable on an En face projection. Switch to a slice view to use it.' : 'This tool is unavailable for a cine clip.'; } - if (effective?.viewInfo.type === 'EnFace' && tool === Tools.Crosshairs) { - return getEnFaceCrosshairsUnavailableReason(effective.renderDataID); - } - if ( - effective?.viewInfo.type === 'EnFace' && - [Tools.WindowLevel, Tools.Pan, Tools.Zoom].includes(tool) - ) { - return getEnFaceUnavailableReason(effective.renderDataID); - } - return null; + if (effective?.viewInfo.type !== 'EnFace' || tool === Tools.Select) + return null; + return tool === Tools.Crosshairs + ? getEnFaceCrosshairsUnavailableReason(effective.renderDataID) + : getEnFaceUnavailableReason(effective.renderDataID); } // These tools draw into the selected segment, so picking one up seats a // segment: the palette shows the color the next stroke will be before it is @@ -176,11 +172,11 @@ export const useToolStore = defineStore('tool', () => { // Shared by the toolbar and the Paint hints, so no hint asks for a disabled tool. const paintUnavailableReason = computed(() => { if (!currentImageID.value) return 'Load an image to paint'; - const kind = activeEffectiveView()?.kind; - if (kind === 'cine') return 'A clip cannot be painted'; - if (activeEffectiveView()?.viewInfo.type === 'EnFace') + const effective = activeEffectiveView(); + if (effective?.kind === 'cine') return 'A clip cannot be painted'; + if (effective?.viewInfo.type === 'EnFace') return 'Painting is not available on an En face projection'; - if (kind === 'oblique') + if (effective?.kind === 'oblique') return 'Painting is not available in an oblique view'; return ''; }); diff --git a/src/store/view-configs/windowing.ts b/src/store/view-configs/windowing.ts index 12d132a54..7befc329f 100644 --- a/src/store/view-configs/windowing.ts +++ b/src/store/view-configs/windowing.ts @@ -10,7 +10,11 @@ import { patchDoubleKeyRecord, } from '@/src/utils/doubleKeyRecord'; import { Maybe } from '@/src/types'; -import { WL_AUTO_DEFAULT, WLAutoRanges } from '@/src/constants'; +import { + WL_AUTO_DEFAULT, + WLAutoRanges, + type WindowingAutoRanges, +} from '@/src/constants'; import { useImageStatsStore } from '@/src/store/image-stats'; import { createViewConfigSerializer } from '@/src/store/view-configs/common'; import type { StateFile, ViewConfig } from '@/src/io/state-file/schema'; @@ -19,11 +23,6 @@ import { isDicomImage } from '@/src/utils/dataSelection'; import { getWindowLevels, useDICOMStore } from '@/src/store/datasets-dicom'; import type { ViewInfo } from '@/src/types/views'; -export type WindowingAutoRanges = Record< - keyof typeof WLAutoRanges, - readonly [number, number] ->; - type WindowingAutoSource = { ranges: WindowingAutoRanges | null; }; diff --git a/src/utils/getViewingVectors.ts b/src/utils/getViewingVectors.ts index a17632118..e2291530b 100644 --- a/src/utils/getViewingVectors.ts +++ b/src/utils/getViewingVectors.ts @@ -5,8 +5,9 @@ export function get2DOrientationForSourceAxis( sourceAxis: 0 | 1 | 2, directions: LPSDirections ) { - const orientation = LPSAxes.find((axis) => directions[axis] === sourceAxis)!; - return { name: orientation, ...get2DViewingVectors(orientation) }; + return get2DViewingVectors( + LPSAxes.find((axis) => directions[axis] === sourceAxis)! + ); } export function get2DViewingVectors(viewerOrientation: LPSAxis) { diff --git a/src/utils/histogram.ts b/src/utils/histogram.ts index d498d6e4c..18a7cc0f8 100644 --- a/src/utils/histogram.ts +++ b/src/utils/histogram.ts @@ -34,7 +34,7 @@ export function histogramWindowRanges( validCount += hist[index]; const span = max - min + 1; const percentileRange = (percentage: number): [number, number] => { - if (min === max || !percentage || !validCount) return [min, max]; + if (min === max || !validCount) return [min, max]; const lowerRank = Math.max(1, Math.ceil(validCount * percentage * 0.01)); const upperRank = Math.ceil(validCount * (1 - percentage * 0.01)); let count = 0; diff --git a/src/utils/imageMetadata.ts b/src/utils/imageMetadata.ts index 21b3c97c3..fea5d8044 100644 --- a/src/utils/imageMetadata.ts +++ b/src/utils/imageMetadata.ts @@ -1,5 +1,4 @@ import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import type { vec3 } from 'gl-matrix'; import type { ImageMetadata } from '@/src/types/image'; import { getLPSDirections } from '@/src/utils/lps'; @@ -7,9 +6,9 @@ export function metadataFromVtkImageData( imageData: vtkImageData ): Omit { return { - dimensions: imageData.getDimensions() as vec3, - spacing: imageData.getSpacing() as vec3, - origin: imageData.getOrigin() as vec3, + dimensions: imageData.getDimensions(), + spacing: imageData.getSpacing(), + origin: imageData.getOrigin(), orientation: imageData.getDirection(), lpsOrientation: getLPSDirections(imageData.getDirection()), worldBounds: imageData.getBounds(), diff --git a/src/utils/layoutParsing.ts b/src/utils/layoutParsing.ts index 26250745c..5872fb9e2 100644 --- a/src/utils/layoutParsing.ts +++ b/src/utils/layoutParsing.ts @@ -146,19 +146,10 @@ const viewSpecToViewInfoInit = (spec: ViewSpec): ViewInfoInit => { }; } - if (spec.type === 'EnFace') { + if (spec.type === 'EnFace' || spec.type === 'Oblique') { return { name: viewSpecName(spec), - type: 'EnFace', - dataID: null, - options: {}, - }; - } - - if (spec.type === 'Oblique') { - return { - name: viewSpecName(spec), - type: 'Oblique', + type: spec.type, dataID: null, options: {}, }; From 334b3be1f961b2467d082020fc93ebdd0c53d003 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 1 Nov 2026 16:37:04 -0500 Subject: [PATCH 3/5] test: share en face test helpers Replace about thirty hand-written sequences that open the En face controls, expand Projection and set the slab with shared helpers, and reuse the page object menu helpers, the tooltip lookup and one tool button selector instead of local copies. Share En face view activation and OCT loading between the unit specs, reuse the image cache cleanup helper, collapse the OCT geometry rejection cases into one table, and replace an assertion that could not fail. The fixture builders now share one DICOM string overwrite, one long-form VR list and one VTI writer. --- .../__tests__/BaseImageProperties.spec.ts | 10 +- .../ControlsStripTools.enFace.spec.ts | 24 +--- .../dicom/__tests__/octMetadata.spec.ts | 108 +++++++--------- src/enface/__tests__/availability.spec.ts | 11 +- src/enface/__tests__/manualVolume.spec.ts | 6 +- src/enface/__tests__/seriesGeometry.spec.ts | 6 +- src/enface/__tests__/summary.spec.ts | 2 +- src/enface/__tests__/viewer.spec.ts | 32 ++--- .../__tests__/enFaceCapabilities.spec.ts | 55 +++----- tests/fixtures/oct/dicom.ts | 20 ++- tests/fixtures/oct/padding.ts | 15 +-- tests/fixtures/oct/volumes.ts | 49 +++++-- tests/specs/oblique-windowing.e2e.ts | 2 +- tests/specs/oct-en-face.e2e.ts | 25 ++-- tests/specs/oct-import.e2e.ts | 16 +-- tests/specs/oct-native-dicom.e2e.ts | 28 ++-- tests/specs/oct-navigation.e2e.ts | 16 +-- tests/specs/octInteractionTestUtils.ts | 35 +---- tests/specs/octTestUtils.ts | 122 ++++++++++-------- tests/specs/projection-camera.e2e.ts | 10 +- tests/specs/projection-contrast.e2e.ts | 23 +--- .../specs/projection-display-controls.e2e.ts | 30 ++--- tests/specs/projection-lifecycle.e2e.ts | 5 +- tests/specs/slab-crosshairs.e2e.ts | 41 +++--- tests/specs/slab-geometry.e2e.ts | 41 +++--- tests/specs/slab-orientation.e2e.ts | 14 +- tests/specs/slab-projection.e2e.ts | 32 ++--- tests/specs/slab-reference-visibility.e2e.ts | 11 +- tests/specs/slab-volume-inputs.e2e.ts | 23 ++-- tests/specs/stock-reslice-slabs.e2e.ts | 17 +-- tests/specs/syntheticDicom.ts | 15 ++- tests/unit/octFixture.ts | 51 ++++++-- 32 files changed, 404 insertions(+), 491 deletions(-) diff --git a/src/components/__tests__/BaseImageProperties.spec.ts b/src/components/__tests__/BaseImageProperties.spec.ts index cc06bc047..3cf920616 100644 --- a/src/components/__tests__/BaseImageProperties.spec.ts +++ b/src/components/__tests__/BaseImageProperties.spec.ts @@ -12,6 +12,7 @@ import { useImageStatsStore } from '@/src/store/image-stats'; import { useViewStore } from '@/src/store/views'; import { useWindowingStore } from '@/src/store/view-configs/windowing'; import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; +import { activateEnFace } from '@/tests/unit/octFixture'; enableAutoUnmount(afterEach); beforeEach(() => setActivePinia(createPinia())); @@ -34,14 +35,7 @@ it('uses a source slice for Layers controls when the active En face cannot proje useImageCacheStore().addVTKImageData(image, '2D image', { id: 'image' }); const views = useViewStore(); views.setDataForAllViews('image'); - views.replaceView(views.layoutViews[0].id, { - type: 'EnFace', - name: 'En face', - dataID: 'image', - options: {}, - }); - const enFaceID = views.layoutViews[0].id; - views.setActiveView(enFaceID); + const enFaceID = activateEnFace('image'); const sourceID = views.visibleViews.find(({ type }) => type === '2D')!.id; const windowing = useWindowingStore(); const wrapper = mount(BaseImageProperties, { diff --git a/src/components/__tests__/ControlsStripTools.enFace.spec.ts b/src/components/__tests__/ControlsStripTools.enFace.spec.ts index a9d70147c..139f039aa 100644 --- a/src/components/__tests__/ControlsStripTools.enFace.spec.ts +++ b/src/components/__tests__/ControlsStripTools.enFace.spec.ts @@ -5,12 +5,15 @@ import { createVuetify } from 'vuetify'; import { nextTick } from 'vue'; import ControlsStripTools from '@/src/components/ControlsStripTools.vue'; import ReasonedAction from '@/src/components/ReasonedAction.vue'; -import { useViewStore } from '@/src/store/views'; import { useToolStore } from '@/src/store/tools'; import { Tools } from '@/src/store/tools/types'; import { useImageCacheStore } from '@/src/store/image-cache'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; -import { createOCTFixture } from '@/tests/unit/octFixture'; +import { + activateEnFace, + createOCTFixture, + loadOCT, +} from '@/tests/unit/octFixture'; import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; import { untilLoaded } from '@/src/composables/untilLoaded'; @@ -21,14 +24,7 @@ beforeEach(() => setActivePinia(createPinia())); afterEach(() => cleanupCachedImages(decodeReleases)); function mountToolbar(dataID: string | null) { - const views = useViewStore(); - views.replaceView(views.layoutViews[0].id, { - type: 'EnFace', - name: 'En face', - options: {}, - dataID, - }); - views.setActiveView(views.layoutViews[0].id); + activateEnFace(dataID); return mount(ControlsStripTools, { attachTo: document.body, global: { @@ -91,13 +87,7 @@ describe('En face contrast toolbar availability', () => { }); it('shows the actual radial-scan reconstruction reason on a loaded unsupported OCT', async () => { - const fixture = createOCTFixture({ scanPattern: '128282' }); - decodeReleases.push(fixture.releaseDecode); - useImageCacheStore().addProgressiveImage(fixture.image, { id: 'radial' }); - await fixture.chunk.loadMeta(); - await fixture.image.addChunks([fixture.chunk]); - fixture.image.startLoad(); - await untilLoaded('radial'); + await loadOCT(decodeReleases, 'radial', { scanPattern: '128282' }); const wrapper = mountToolbar('radial'); expect(wrapper.get(windowTool).attributes('disabled')).toBeDefined(); expect(windowAction(wrapper).get(':scope > [role="tooltip"]').text()).toBe( diff --git a/src/core/streaming/dicom/__tests__/octMetadata.spec.ts b/src/core/streaming/dicom/__tests__/octMetadata.spec.ts index a451b29a9..7301b0b5e 100644 --- a/src/core/streaming/dicom/__tests__/octMetadata.spec.ts +++ b/src/core/streaming/dicom/__tests__/octMetadata.spec.ts @@ -22,6 +22,7 @@ import { element, enhancedFile, sequence, + overwriteDicomString, } from '@/tests/fixtures/oct/dicom'; const baseMetadata = (): Array<[string, string]> => [ @@ -42,19 +43,6 @@ function readDataset(bytes: Uint8Array) { return dicomParser.parseDicom(bytes, { untilTag: 'x7fe00010' }); } -function overwrite(data: DataSet, tag: string, value: string) { - const attribute = data.elements[tag]; - const bytes = new TextEncoder().encode(value); - if (bytes.length > attribute.length) - throw new Error('Test replacement exceeds DICOM value length'); - data.byteArray.fill( - 0x20, - attribute.dataOffset, - attribute.dataOffset + attribute.length - ); - data.byteArray.set(bytes, attribute.dataOffset); -} - function hide(data: DataSet, tag: string) { const attribute = data.elements[tag]; // Explicit-VR DS headers are 8 bytes. Rename to an unused private group. @@ -249,7 +237,7 @@ describe('OCT enhanced spatial metadata', () => { it('preserves declared scan patterns such as radial scans', async () => { const bytes = fixtureBytes(); const data = readDataset(bytes); - overwrite(firstItem(data, 'x00221618'), 'x00080100', '128282'); + overwriteDicomString(firstItem(data, 'x00221618'), 'x00080100', '128282'); const tags = await augmented(bytes); expect(isOCTMetadata(tags)).toBe(true); expect(tags.get(OCT_SCAN_PATTERN)).toBe('128282'); @@ -316,7 +304,7 @@ describe('OCT enhanced spatial metadata', () => { it('preserves declarations marking a volume unsuitable for volumetric processing', async () => { const bytes = fixtureBytes(); - overwrite(readDataset(bytes), 'x00221622', 'NO'); + overwriteDicomString(readDataset(bytes), 'x00221622', 'NO'); expect( (await augmented(bytes)).get(Tags.OphthalmicVolumetricPropertiesFlag) ).toBe('NO'); @@ -337,7 +325,7 @@ describe('OCT enhanced spatial metadata', () => { it('rejects a per-frame geometry count that disagrees with NumberOfFrames', async () => { const bytes = fixtureBytes(); - overwrite(readDataset(bytes), 'x00280008', '31'); + overwriteDicomString(readDataset(bytes), 'x00280008', '31'); expect(spatialMetadataError(await augmented(bytes))).toMatch( /frame.*count|number.*frames/i ); @@ -354,7 +342,7 @@ describe('OCT enhanced spatial metadata', () => { const bytes = fixtureBytes(); const data = readDataset(bytes); const shared = firstItem(data, 'x52009229'); - overwrite( + overwriteDicomString( firstItem(shared, 'x00209116'), 'x00200037', orientation.join('\\') @@ -371,13 +359,13 @@ describe('OCT enhanced spatial metadata', () => { const bytes = fixtureBytes(); const data = readDataset(bytes); if (kind === 'position') { - overwrite( + overwriteDicomString( firstItem(firstItem(data, 'x52009230'), 'x00209113'), 'x00200032', '0\\0' ); } else { - overwrite( + overwriteDicomString( firstItem(firstItem(data, 'x52009229'), 'x00209116'), 'x00200037', kind === 'orientation' ? '1\\0\\0\\0\\1' : '2\\0\\0\\0\\.5\\0' @@ -410,7 +398,7 @@ describe('OCT enhanced spatial metadata', () => { const data = readDataset(bytes); const finalFrame = data.elements.x52009230.items!.at(-1)!.dataSet!; const position = firstItem(finalFrame, 'x00209113'); - overwrite(position, 'x00200032', '0.0\\0.0\\5.8225'); + overwriteDicomString(position, 'x00200032', '0.0\\0.0\\5.8225'); expect( spatialMetadataError( await augmented(deflated ? deflatedExplicitVR(bytes) : bytes) @@ -423,7 +411,7 @@ describe('OCT enhanced spatial metadata', () => { const bytes = fixtureBytes(); const data = readDataset(bytes); const secondFrame = data.elements.x52009230.items![1].dataSet!; - overwrite( + overwriteDicomString( firstItem(secondFrame, 'x00209113'), 'x00200032', '1.0\\0.0\\0.1875' @@ -460,7 +448,7 @@ describe('OCT enhanced spatial metadata', () => { it('accepts rounded orthogonal direction cosines', async () => { const bytes = fixtureBytes(); - overwrite( + overwriteDicomString( firstItem(firstItem(readDataset(bytes), 'x52009229'), 'x00209116'), 'x00200037', '.8660\\.5\\0\\-.5\\.8660\\0' @@ -506,7 +494,7 @@ describe('OCT enhanced spatial metadata', () => { : [] ).arrayBuffer() ); - overwrite( + overwriteDicomString( firstItem(firstItem(readDataset(bytes), 'x52009229'), 'x00209116'), 'x00200037', ' ' @@ -577,50 +565,46 @@ describe('OCT enhanced spatial metadata', () => { expect(tags.get(Tags.SpacingBetweenSlices)).toBe('0.2'); }); - it('rejects frames with inconsistent pixel spacing', async () => { - const tags = await augmentOCTMetadata( - enhancedFile([ + it.each([ + [ + 'frames with inconsistent pixel spacing', + [ { position: '0\\0\\0' }, { position: '0\\0\\0.2', spacing: '0.02\\0.02' }, - ]), - baseMetadata() - ); - expect(spatialMetadataError(tags)).toMatch(/different pixel spacing/); - }); - - it('rejects unequal per-frame slice spacing without positions', async () => { - const tags = await augmentOCTMetadata( - enhancedFile([{ slowSpacing: '0.2' }, { slowSpacing: '0.3' }]), - baseMetadata() - ); - expect(spatialMetadataError(tags)).toMatch(/slice spacing/); - }); - - it('rejects nonparallel frames', async () => { - const tags = await augmentOCTMetadata( - enhancedFile([ + ], + /different pixel spacing/, + ], + [ + 'unequal per-frame slice spacing without positions', + [{ slowSpacing: '0.2' }, { slowSpacing: '0.3' }], + /slice spacing/, + ], + [ + 'nonparallel frames', + [ { position: '0\\0\\0' }, { position: '0\\0\\0.2', orientation: '0\\1\\0\\1\\0\\0' }, - ]), - baseMetadata() - ); - expect(spatialMetadataError(tags)).toMatch(/not parallel/); - }); - - it.each([ - [{ position: '0\\0\\0' }, {}], - [{ position: '0\\0\\0' }, { position: '0\\0\\0.2' }, {}], - ])('rejects partially missing frame positions %#', async (...frames) => { + ], + /not parallel/, + ], + [ + 'a frame missing its position', + [{ position: '0\\0\\0' }, {}], + /incomplete/, + ], + [ + 'a frame missing its position after a positioned pair', + [{ position: '0\\0\\0' }, { position: '0\\0\\0.2' }, {}], + /incomplete/, + ], + [ + 'repeated B-scans rather than stacking duplicate locations', + [{ position: '0\\0\\0' }, { position: '0\\0\\0' }], + /Repeated/, + ], + ])('rejects %s', async (_label, frames, error) => { const tags = await augmentOCTMetadata(enhancedFile(frames), baseMetadata()); - expect(spatialMetadataError(tags)).toMatch(/incomplete/); - }); - - it('rejects repeated B-scans rather than stacking duplicate locations', async () => { - const tags = await augmentOCTMetadata( - enhancedFile([{ position: '0\\0\\0' }, { position: '0\\0\\0' }]), - baseMetadata() - ); - expect(spatialMetadataError(tags)).toMatch(/Repeated/); + expect(spatialMetadataError(tags)).toMatch(error); }); it('records unreadable spatial metadata when the OCT header cannot be parsed', async () => { diff --git a/src/enface/__tests__/availability.spec.ts b/src/enface/__tests__/availability.spec.ts index c2839166a..f2c88feb8 100644 --- a/src/enface/__tests__/availability.spec.ts +++ b/src/enface/__tests__/availability.spec.ts @@ -5,6 +5,7 @@ import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import { useImageCacheStore } from '@/src/store/image-cache'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; import { createOCTFixture } from '@/tests/unit/octFixture'; +import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; import { untilLoaded } from '@/src/composables/untilLoaded'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; @@ -12,17 +13,13 @@ import { Tags } from '@/src/core/dicomTags'; describe('OCT view availability', () => { let emptyImage: DicomChunkImage | undefined; - let releaseDecode: (() => void) | undefined; + const decodeReleases: (() => void)[] = []; beforeEach(() => setActivePinia(createPinia())); afterEach(async () => { - releaseDecode?.(); - const cache = useImageCacheStore(); - [...cache.imageIds].forEach((id) => cache.removeImage(id)); + await cleanupCachedImages(decodeReleases); emptyImage?.dispose(); emptyImage = undefined; - releaseDecode = undefined; - await nextTick(); }); it('keeps the view disabled while a cached DICOM image has no metadata chunks', () => { @@ -38,7 +35,7 @@ describe('OCT view availability', () => { it('reacts to metadata allocation and complete pixel loading after initially empty raw chunks', async () => { const fixture = createOCTFixture({ deferDecode: true }); - releaseDecode = fixture.releaseDecode; + decodeReleases.push(fixture.releaseDecode); useImageCacheStore().addProgressiveImage(fixture.image, { id: 'pending' }); const reason = computed(() => getEnFaceUnavailableReason('pending')); expect(reason.value).not.toBeNull(); diff --git a/src/enface/__tests__/manualVolume.spec.ts b/src/enface/__tests__/manualVolume.spec.ts index dc59d64dd..a2ef572a4 100644 --- a/src/enface/__tests__/manualVolume.spec.ts +++ b/src/enface/__tests__/manualVolume.spec.ts @@ -4,7 +4,6 @@ import { mkdtempSync, unlinkSync, rmdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createPinia, setActivePinia } from 'pinia'; -import { nextTick } from 'vue'; import { readImageNode } from '@itk-wasm/image-io'; import { gzipSync } from 'node:zlib'; import { @@ -15,6 +14,7 @@ import { import { convertItkToVtkImage } from '@/src/io/convertItkToVtkImage'; import { repairUnusableSpacing } from '@/src/utils/imageSpace'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; const samples = Uint16Array.from({ length: 12 }, (_, index) => index); @@ -84,9 +84,7 @@ describe('manual En face with real generic image readers', () => { paths = []; }); afterEach(async () => { - const cache = useImageCacheStore(); - [...cache.imageIds].forEach((id) => cache.removeImage(id)); - await nextTick(); + await cleanupCachedImages([]); paths.forEach((path) => unlinkSync(path)); rmdirSync(directory); }); diff --git a/src/enface/__tests__/seriesGeometry.spec.ts b/src/enface/__tests__/seriesGeometry.spec.ts index e44e82907..1fd1f4dd0 100644 --- a/src/enface/__tests__/seriesGeometry.spec.ts +++ b/src/enface/__tests__/seriesGeometry.spec.ts @@ -7,6 +7,7 @@ import { Tags } from '@/src/core/dicomTags'; import { useImageCacheStore } from '@/src/store/image-cache'; import { untilLoaded } from '@/src/composables/untilLoaded'; import { createOCTFixture, readDicomTags } from '@/tests/unit/octFixture'; +import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; import { buildSyntheticDicom } from '@/tests/specs/syntheticDicom'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; import { @@ -16,10 +17,7 @@ import { describe('assembled OCT series through real chunk and file collaborators', () => { beforeEach(() => setActivePinia(createPinia())); - afterEach(() => { - const cache = useImageCacheStore(); - [...cache.imageIds].forEach((id) => cache.removeImage(id)); - }); + afterEach(() => cleanupCachedImages([])); async function assemble( positions = [0, 0.2, 0.4], diff --git a/src/enface/__tests__/summary.spec.ts b/src/enface/__tests__/summary.spec.ts index 38d1ebea3..64ced87a6 100644 --- a/src/enface/__tests__/summary.spec.ts +++ b/src/enface/__tests__/summary.spec.ts @@ -22,7 +22,7 @@ describe('En face projection announcements', () => { await wrapper.setProps({ message: null, }); - expect(wrapper.find('button').exists()).toBe(false); + expect(wrapper.text()).toBe(''); await wrapper.setProps({ message: null, ready: true }); expect(wrapper.text()).toBe('En face projection ready.'); expect(wrapper.classes()).toContain('d-sr-only'); diff --git a/src/enface/__tests__/viewer.spec.ts b/src/enface/__tests__/viewer.spec.ts index ab20ada8a..f721d8674 100644 --- a/src/enface/__tests__/viewer.spec.ts +++ b/src/enface/__tests__/viewer.spec.ts @@ -16,7 +16,11 @@ import { Tools } from '@/src/store/tools/types'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useImageStatsStore } from '@/src/store/image-stats'; import { untilLoaded } from '@/src/composables/untilLoaded'; -import { createOCTFixture } from '@/tests/unit/octFixture'; +import { + activateEnFace, + createOCTFixture, + loadOCT, +} from '@/tests/unit/octFixture'; import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; const releases: (() => void)[] = []; @@ -43,14 +47,7 @@ function mountViewer(viewID: string) { it('enables pane interactions only when their projection source supports them', async () => { const views = useViewStore(); - views.replaceView(views.layoutViews[0].id, { - name: 'En face', - type: 'EnFace', - dataID: null, - options: {}, - }); - const viewID = views.layoutViews[0].id; - views.setActiveView(viewID); + const viewID = activateEnFace(null); const tools = useToolStore(); const wrapper = mountViewer(viewID); const button = () => @@ -111,26 +108,13 @@ it.each(['data', 'view'] as const)( 'does not recreate removed %s settings while its viewer is still mounted', async (removed) => { const views = useViewStore(); - views.replaceView(views.layoutViews[0].id, { - name: 'En face', - type: 'EnFace', - dataID: 'oct', - options: {}, - }); - const viewID = views.layoutViews[0].id; - views.setActiveView(viewID); + const viewID = activateEnFace('oct'); useImageStatsStore().stats.oct = { scalarMin: 0, scalarMax: 768, autoRangeValues: {}, }; - const fixture = createOCTFixture(); - releases.push(fixture.releaseDecode); - useImageCacheStore().addProgressiveImage(fixture.image, { id: 'oct' }); - await fixture.chunk.loadMeta(); - await fixture.image.addChunks([fixture.chunk]); - fixture.image.startLoad(); - await untilLoaded('oct'); + await loadOCT(releases, 'oct'); const wrapper = mountViewer(viewID); const store = useEnFaceViewStore(); const savedSettings = () => { diff --git a/src/store/tools/__tests__/enFaceCapabilities.spec.ts b/src/store/tools/__tests__/enFaceCapabilities.spec.ts index e28dd8621..32f4c4b14 100644 --- a/src/store/tools/__tests__/enFaceCapabilities.spec.ts +++ b/src/store/tools/__tests__/enFaceCapabilities.spec.ts @@ -20,7 +20,7 @@ import { import { useViewStore } from '@/src/store/views'; import { useImageCacheStore } from '@/src/store/image-cache'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; -import { createOCTFixture } from '@/tests/unit/octFixture'; +import { activateEnFace, loadOCT } from '@/tests/unit/octFixture'; import { cleanupCachedImages } from '@/tests/unit/imageCacheCleanup'; import { untilLoaded } from '@/src/composables/untilLoaded'; import type { ViewInfoEnFace } from '@/src/types/views'; @@ -41,34 +41,7 @@ const supportedTools = new Set([ Tools.Crosshairs, ]); -function activateEnFace(dataID = view.dataID) { - const views = useViewStore(); - const previous = views.layoutViews[0].id; - views.replaceView(previous, { - name: view.name, - type: view.type, - dataID, - options: {}, - }); - const current = views.layoutViews[0].id; - views.setActiveView(current); - return current; -} - const decodeReleases: (() => void)[] = []; -async function loadOCT( - id = 'retina-oct', - options: Parameters[0] = {} -) { - const fixture = createOCTFixture(options); - decodeReleases.push(fixture.releaseDecode); - useImageCacheStore().addProgressiveImage(fixture.image, { id }); - await fixture.chunk.loadMeta(); - await fixture.image.addChunks([fixture.chunk]); - fixture.image.startLoad(); - if (!options.deferDecode) await untilLoaded(id); - return id; -} function addRegularVolume(flat = false) { const image = vtkImageData.newInstance(); @@ -111,9 +84,9 @@ describe('temporary crosshairs in the toolbar', () => { ])( 'restores $initial after focusing an unavailable En face view while held', async ({ initial, pending }) => { - const imageID = await loadOCT(); + const imageID = await loadOCT(decodeReleases); const pendingID = pending - ? await loadOCT('pending', { deferDecode: true }) + ? await loadOCT(decodeReleases, 'pending', { deferDecode: true }) : null; useViewStore().setDataForAllViews(imageID); vi.stubGlobal('visualViewport', null); @@ -148,7 +121,7 @@ describe('temporary crosshairs in the toolbar', () => { it.each(['denied', 'successful', 'En face', 'focus change'])( 'preserves selection after a %s hold', async (scenario) => { - const imageID = await loadOCT(); + const imageID = await loadOCT(decodeReleases); const views = useViewStore(); views.setDataForAllViews(imageID); if (scenario === 'denied') activateEnFace(addRegularVolume(true)); @@ -239,19 +212,19 @@ describe('activating an En face view', () => { it('keeps Pan selected when changing to an En face view', async () => { const tools = useToolStore(); tools.setCurrentTool(Tools.Pan); - activateEnFace(await loadOCT()); + activateEnFace(await loadOCT(decodeReleases)); await nextTick(); expect(tools.currentTool).toBe(Tools.Pan); }); it('keeps the standard window/level tool active when supported OCT pixels are loaded', async () => { const tools = useToolStore(); expect(tools.currentTool).toBe(Tools.WindowLevel); - activateEnFace(await loadOCT()); + activateEnFace(await loadOCT(decodeReleases)); await nextTick(); expect(tools.currentTool).toBe(Tools.WindowLevel); }); it('allows the contrast shortcut while refusing unsupported projection tools', async () => { - activateEnFace(await loadOCT()); + activateEnFace(await loadOCT(decodeReleases)); const tools = useToolStore(); await nextTick(); expect(tools.currentTool).toBe(Tools.WindowLevel); @@ -287,15 +260,19 @@ describe('activating an En face view', () => { it.each(['2D', 'radial', 'incomplete', 'missing'])( 'disables contrast after replacing En face with %s data without changing the view kind', async (replacement) => { - const viewID = activateEnFace(await loadOCT()); + const viewID = activateEnFace(await loadOCT(decodeReleases)); const tools = useToolStore(); expect(tools.currentTool).toBe(Tools.WindowLevel); let dataID = 'missing'; if (replacement === '2D') dataID = addRegularVolume(true); if (replacement === 'radial') - dataID = await loadOCT('radial', { scanPattern: '128282' }); + dataID = await loadOCT(decodeReleases, 'radial', { + scanPattern: '128282', + }); if (replacement === 'incomplete') - dataID = await loadOCT('pending', { deferDecode: true }); + dataID = await loadOCT(decodeReleases, 'pending', { + deferDecode: true, + }); useViewStore().setDataForView(viewID, dataID); await nextTick(); const effective = getEffectiveView(viewID); @@ -338,7 +315,9 @@ describe('activating an En face view', () => { const viewID = activateEnFace(null); await nextTick(); expect(tools.currentTool).toBe(tool); - const dataID = await loadOCT('pending', { deferDecode: true }); + const dataID = await loadOCT(decodeReleases, 'pending', { + deferDecode: true, + }); useViewStore().setDataForView(viewID, dataID); await nextTick(); expect(tools.currentTool).toBe(tool); diff --git a/tests/fixtures/oct/dicom.ts b/tests/fixtures/oct/dicom.ts index 38eb2fa58..731fcf23d 100644 --- a/tests/fixtures/oct/dicom.ts +++ b/tests/fixtures/oct/dicom.ts @@ -1,5 +1,6 @@ +import type { DataSet } from 'dicom-parser'; import reference from './reference.json'; -import { elemShort, elemLong } from '../../specs/syntheticDicom'; +import { elemShort, elemLong, LONG_FORM_VRS } from '../../specs/syntheticDicom'; const concat = (chunks: Uint8Array[]) => Buffer.concat(chunks); @@ -7,7 +8,7 @@ export function element(tag: string, vr: string, value: string | Uint8Array) { let payload = typeof value === 'string' ? Buffer.from(value) : value; if (payload.length % 2) payload = concat([payload, Uint8Array.of(vr === 'UI' ? 0 : 32)]); - const encode = ['SQ', 'OW', 'OB'].includes(vr) ? elemLong : elemShort; + const encode = LONG_FORM_VRS.has(vr) ? elemLong : elemShort; return encode( parseInt(tag.slice(0, 4), 16), parseInt(tag.slice(4), 16), @@ -16,6 +17,21 @@ export function element(tag: string, vr: string, value: string | Uint8Array) { ); } +/** Overwrites a fixed-length string value in place, padding the remainder. */ +export function overwriteDicomString( + data: DataSet, + tag: string, + value: string, + pad = 0x20 +) { + const { dataOffset, length } = data.elements[tag]; + const bytes = new TextEncoder().encode(value); + if (bytes.length > length) + throw new Error('Replacement exceeds the DICOM value length'); + data.byteArray.fill(pad, dataOffset, dataOffset + length); + data.byteArray.set(bytes, dataOffset); +} + export function sequence(tag: string, datasets: Uint8Array[]) { return element( tag, diff --git a/tests/fixtures/oct/padding.ts b/tests/fixtures/oct/padding.ts index 2f5edf14f..338fc8d15 100644 --- a/tests/fixtures/oct/padding.ts +++ b/tests/fixtures/oct/padding.ts @@ -1,5 +1,5 @@ import dicomParser from 'dicom-parser'; -import { elemShort } from '../../specs/syntheticDicom'; +import { elemShort, LONG_FORM_VRS } from '../../specs/syntheticDicom'; function paddingElement() { return elemShort(0x0028, 0x0120, 'US', Uint8Array.of(255, 255)); @@ -12,18 +12,7 @@ export function paddedSyntheticOCT(original: Uint8Array) { .sort() .find((tag) => tag > 'x00280120')!; const element = source.elements[next]; - const longVR = [ - 'OB', - 'OD', - 'OF', - 'OL', - 'OW', - 'SQ', - 'UC', - 'UN', - 'UR', - 'UT', - ].includes(element.vr!); + const longVR = LONG_FORM_VRS.has(element.vr!); const position = element.dataOffset - (longVR ? 12 : 8); const bytes = Buffer.concat([ original.subarray(0, position), diff --git a/tests/fixtures/oct/volumes.ts b/tests/fixtures/oct/volumes.ts index 0dc97c96e..f2b48de77 100644 --- a/tests/fixtures/oct/volumes.ts +++ b/tests/fixtures/oct/volumes.ts @@ -1,7 +1,7 @@ import dicomParser from 'dicom-parser'; import { deflateRawSync, gzipSync } from 'node:zlib'; import reference from './reference.json'; -import { syntheticScalars } from './dicom'; +import { overwriteDicomString, syntheticScalars } from './dicom'; import { elemShort } from '../../specs/syntheticDicom'; /** Preserve a Part 10 dataset while changing only its transfer syntax. */ @@ -46,17 +46,8 @@ export function nonOCTDicom(source: Uint8Array) { ['x00020002', '1.2.840.10008.5.1.4.1.1.7.3'], ['x00080016', '1.2.840.10008.5.1.4.1.1.7.3'], ['x00080060', 'CT'], - ]) { - const element = dicom.elements[tag]; - if (value.length > element.length) - throw new Error('Replacement DICOM tag is too long'); - const target = bytes.subarray( - element.dataOffset, - element.dataOffset + element.length - ); - target.fill(tag === 'x00080060' ? 32 : 0); - target.write(value, 'ascii'); - } + ]) + overwriteDicomString(dicom, tag, value, tag === 'x00080060' ? 0x20 : 0); return bytes; } @@ -149,6 +140,40 @@ export function createNiftiVolume({ return Buffer.concat([header, scalarBytes(scalars)]); } +/** ASCII VTK image data, for grids NRRD and NIfTI cannot express such as offset extents. */ +export function createVtiVolume({ + values, + extent, + spacing = [1, 1, 1], + origin = [0, 0, 0], + direction, + type, + name = 'intensity', + components, +}: { + values: ArrayLike; + extent: readonly number[]; + spacing?: readonly number[]; + origin?: readonly number[]; + direction?: readonly number[]; + type: 'UInt8' | 'UInt16' | 'Float32'; + name?: string; + components?: number; +}) { + const range = extent.join(' '); + return ( + '' + + `` + + `' + + Array.from(values).join(' ') + + '' + ); +} + export function syntheticOCTGrid() { const [width, depth, frames] = reference.size; const [sx, sy, sz] = reference.spacing; diff --git a/tests/specs/oblique-windowing.e2e.ts b/tests/specs/oblique-windowing.e2e.ts index 2eb29292a..2b694e18c 100644 --- a/tests/specs/oblique-windowing.e2e.ts +++ b/tests/specs/oblique-windowing.e2e.ts @@ -8,9 +8,9 @@ import { displayedWindow, dragContrast, stageGenericOCT, + WINDOW_TOOL, } from './octTestUtils'; -const WINDOW_TOOL = 'button[data-testid^="control-button-Window & Level "]'; const OBLIQUE_CHILD = '.oblique-grid > .d-flex:first-child [data-testid="vtk-view"]'; const SOURCE_PEER = '[data-testid~="vtk-two-view"]'; diff --git a/tests/specs/oct-en-face.e2e.ts b/tests/specs/oct-en-face.e2e.ts index 077b8a850..7e5e0e86b 100644 --- a/tests/specs/oct-en-face.e2e.ts +++ b/tests/specs/oct-en-face.e2e.ts @@ -4,7 +4,6 @@ import { Key } from 'webdriverio'; import { waitForEnFaceReady, expectEnFaceDisabledInSwitcher, - expandEnFaceSection, setSlabRange, setEnFaceWindow, slabThumb, @@ -13,6 +12,9 @@ import { CONTROLS, PANEL, WINDOW_TOOL, + openEnFaceProjection, + openEnFaceControls, + selectSetting, } from './octTestUtils'; import { TEMP_DIR } from '../../wdio.shared.conf'; import { volViewPage } from '../pageobjects/volview.page'; @@ -30,8 +32,6 @@ import { reference, STEM, stageFixture, - openControls, - selectSetting, expectProjectionPixel, expectCalibratedEnFace, openRetina, @@ -54,8 +54,7 @@ describe('Retinal OCT en-face', () => { }; await $(CONTROLS).doubleClick(); await expectBothViews(); - await openControls(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); await slabThumb('start').doubleClick(); await expectBothViews(); await browser.keys('Escape'); @@ -94,8 +93,7 @@ describe('Retinal OCT en-face', () => { await setEnFaceWindow(1000, 500); // The standard physical slab has mean 317, maximum 538 and sampled sum 140431. await expectProjectionPixel(81); - await openControls(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); await selectSetting('enface-projection-method', 'Maximum'); await waitForEnFaceReady(); await expect( @@ -103,7 +101,7 @@ describe('Retinal OCT en-face', () => { ).toHaveValue('Maximum'); await setEnFaceWindow(1000, 500); await expectProjectionPixel(137); - await openControls(); + await openEnFaceControls(); await selectSetting('enface-projection-method', 'Sum'); await waitForEnFaceReady(); await expect( @@ -111,16 +109,15 @@ describe('Retinal OCT en-face', () => { ).toHaveValue('Sum'); await setEnFaceWindow(200000, 100000); await expectProjectionPixel(179); - await openControls(); + await openEnFaceControls(); await selectSetting('enface-projection-method', 'Mean'); await setEnFaceWindow(1000, 500); - await openControls(); + await openEnFaceControls(); await setSlabRange(reference.slab.start, reference.slab.end); await waitForEnFaceReady(); await expectProjectionPixel(76); await browser.keys('Escape'); - await openControls(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); await selectSetting('enface-projection-axis', 'Sagittal'); await waitForEnFaceReady(); await expect( @@ -141,7 +138,7 @@ describe('Retinal OCT en-face', () => { for (const label of await labels) await expect(label).not.toBeDisplayed(); await setEnFaceWindow(1000, 500); await expectProjectionPixel(81); - await openControls(); + await openEnFaceControls(); const start = slabThumb('start'); const end = slabThumb('end'); await start.scrollIntoView({ block: 'center', inline: 'nearest' }); @@ -274,7 +271,7 @@ describe('Retinal OCT en-face', () => { await waitForEnFaceReady(); await setEnFaceWindow(1000, 500); await expectProjectionPixel(81); - await openControls(); + await openEnFaceControls(); await selectSetting('enface-projection-method', 'Maximum'); await browser.keys('Escape'); await waitForEnFaceReady(); diff --git a/tests/specs/oct-import.e2e.ts b/tests/specs/oct-import.e2e.ts index c8072fbb4..4c8a49423 100644 --- a/tests/specs/oct-import.e2e.ts +++ b/tests/specs/oct-import.e2e.ts @@ -14,6 +14,9 @@ import { selectEnFace, selectTwoRowLayout, CANVAS, + openEnFaceProjection, + openEnFaceControls, + selectSetting, } from './octTestUtils'; import { TEMP_DIR } from '../../wdio.shared.conf'; import { volViewPage } from '../pageobjects/volview.page'; @@ -33,8 +36,6 @@ import { reference, STEM, stageFixture, - openControls, - selectSetting, expectProjectionPixel, dragEnFaceContrast, expectStackedMaskWithoutSidebar, @@ -79,7 +80,7 @@ describe('Retinal OCT import', () => { await selectSegment('Synthetic retinal layer'); await expectStackedMaskWithoutSidebar(); - await openControls(); + await openEnFaceControls(); await expectControlsBesideSelector(); await browser.keys('Escape'); await dragEnFaceContrast(); @@ -90,7 +91,7 @@ describe('Retinal OCT import', () => { expect(await getWindow()).toEqual(retainedWindow); expect(await volViewPage.getFirst2DSlice()).toBe(retainedSlice); await expectSliceLine(); - await openControls(); + await openEnFaceControls(); await browser.keys('Escape'); await volViewPage.focusFirst2DView(); const before = await volViewPage.getFirst2DSlice(); @@ -147,13 +148,13 @@ describe('Retinal OCT import', () => { await selectEnFace(); await waitForEnFaceReady(); expect((await $$('.view-type-select')).length).toBe(viewCount); - await openControls(); + await openEnFaceControls(); expect( await $( '.v-overlay--active [data-testid="enface-projection-toggle"]' ).getAttribute('aria-expanded') ).toBe('true'); - await expandEnFaceSection('projection'); + await expandEnFaceSection(); await selectSetting('enface-projection-axis', projectionAxis); await waitForEnFaceReady(); const display = await $(CANVAS).getSize(); @@ -172,8 +173,7 @@ describe('Retinal OCT import', () => { await volViewPage.waitForViews(); await $(CANVAS).waitForDisplayed(); await waitForEnFaceReady(); - await openControls(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); expect( await $( '.v-overlay--active [data-testid="enface-projection-axis"] input' diff --git a/tests/specs/oct-native-dicom.e2e.ts b/tests/specs/oct-native-dicom.e2e.ts index f0a31c3a9..bfd12f705 100644 --- a/tests/specs/oct-native-dicom.e2e.ts +++ b/tests/specs/oct-native-dicom.e2e.ts @@ -22,8 +22,9 @@ import { selectEnFace, selectTwoRowLayout, CANVAS, - PANEL, - CONTROLS, + openEnFaceProjection, + openEnFaceControls, + setEnFaceSlab, } from './octTestUtils'; import { openAnnotationSegments, @@ -47,13 +48,6 @@ async function openNativeDicom() { await volViewPage.waitForViewCounts(1, false); } -async function setSlab(start: number, end: number) { - await $('[data-testid="enface-controls"]').click(); - await $(PANEL).waitForDisplayed(); - await expandEnFaceSection('projection'); - await setSlabRange(start, end); -} - async function magnifyNativeCenter() { const canvas = await $(CANVAS).getElement(); await $('button[data-testid^="control-button-Zoom "]').click(); @@ -197,18 +191,14 @@ describe('Public OCT exports', () => { expect(await volViewPage.getNotificationsCount()).toBe(0); } - await (await $$('.view-type-select .v-field'))[1].click(); - await overlayOption('En face').waitForClickable(); - await overlayOption('En face').click(); + await selectEnFace(1); await waitForEnFaceReady(); - await $(CONTROLS).click(); - await $(PANEL).waitForDisplayed(); - await $(PANEL).waitForStable(); + await openEnFaceControls(); await expect($('[data-testid="enface-projection-toggle"]')).toHaveAttribute( 'aria-expanded', 'true' ); - await expandEnFaceSection('projection'); + await expandEnFaceSection(); const axis = $('.v-overlay--active [data-testid="enface-projection-axis"]'); await axis.scrollIntoView({ block: 'center' }); await axis.waitForClickable(); @@ -294,7 +284,8 @@ describe('Public OCT exports', () => { expect(value).toBeCloseTo(native.spacing[axis], 6) ); const fullProjection = await enFaceAppearance(); - await setSlab(100, 300); + await openEnFaceProjection(); + await setSlabRange(100, 300); await browser.waitUntil( async () => (await enFaceAppearance()).image !== fullProjection.image ); @@ -327,8 +318,7 @@ describe('Public OCT exports', () => { scenario.method ); if (scenario.field === 'slab100to300mean') { - await setSlab(100, 300); - await browser.keys('Escape'); + await setEnFaceSlab(100, 300); } await waitForEnFaceReady(); await setEnFaceWindow(scenario.width, scenario.width / 2); diff --git a/tests/specs/oct-navigation.e2e.ts b/tests/specs/oct-navigation.e2e.ts index 2510d96f3..830f93028 100644 --- a/tests/specs/oct-navigation.e2e.ts +++ b/tests/specs/oct-navigation.e2e.ts @@ -16,6 +16,9 @@ import { CANVAS, PANEL, WINDOW_TOOL, + openEnFaceProjection, + openEnFaceControls, + selectSetting, } from './octTestUtils'; import { TEMP_DIR } from '../../wdio.shared.conf'; import { volViewPage } from '../pageobjects/volview.page'; @@ -23,8 +26,6 @@ import { waitForDownload, SESSION_SAVE_TIMEOUT } from './utils'; import { reference, SLICE_LINE, - openControls, - selectSetting, expectProjectionPixel, dragEnFaceContrast, getOriginalWindow, @@ -38,7 +39,7 @@ describe('Retinal OCT navigation', () => { registerOctTestHooks(); it('restores a nondefault projection axis without resetting the saved slab', async () => { await openRetina(); - await expandEnFaceSection('projection'); + await expandEnFaceSection(); await selectSetting('enface-projection-axis', 'Sagittal'); await setSlabRange(10, 100); await selectSetting('enface-projection-method', 'Maximum'); @@ -54,7 +55,7 @@ describe('Retinal OCT navigation', () => { await volViewPage.open('?urls=[tmp/' + saved + ']'); await $(CANVAS).waitForDisplayed(); await waitForEnFaceReady(); - await openControls(); + await openEnFaceControls(); await expectEnFaceProjectionSettings('Sagittal', 10, 100); expect(await volViewPage.getNotificationsCount()).toBe(0); }); @@ -119,8 +120,7 @@ describe('Retinal OCT navigation', () => { await $('button[data-testid^="control-button-Pan "]').click(); await dragContrast(await $(CANVAS).getElement(), 55, 30); - await openControls(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); await selectSetting('enface-projection-axis', 'Sagittal'); await waitForEnFaceReady(); await browser.keys('Escape'); @@ -347,7 +347,7 @@ describe('Retinal OCT navigation', () => { await expectSliceLine(); // Select before capturing the comparison with a textured overlay. await $('button[data-testid^="control-button-Select "]').click(); - await openControls(); + await openEnFaceControls(); await expectSliceLine(); await expectControlsBesideSelector(); const captureDirectory = process.env.VOLVIEW_OCT_CAPTURE_DIR; @@ -371,7 +371,7 @@ describe('Retinal OCT navigation', () => { await volViewPage.waitForViews(); await $(CANVAS).waitForDisplayed(); await waitForEnFaceReady(); - await openControls(); + await openEnFaceControls(); await browser.keys('Escape'); await expectSliceLine(); expect(await getWindow()).toEqual(savedWindow); diff --git a/tests/specs/octInteractionTestUtils.ts b/tests/specs/octInteractionTestUtils.ts index b9b12d26f..0da7f8383 100644 --- a/tests/specs/octInteractionTestUtils.ts +++ b/tests/specs/octInteractionTestUtils.ts @@ -11,13 +11,14 @@ import { getEnFaceWindow as getWindow, displayedWindow, selectEnFace, + openEnFaceControls, CANVAS, CONTROLS, PANEL, WINDOW_TOOL, } from './octTestUtils'; import { volViewPage } from '../pageobjects/volview.page'; -import { makeTempDir, writeManifestToFile, overlayOption } from './utils'; +import { makeTempDir, writeManifestToFile } from './utils'; import { applyTestViewport } from '../../wdio.shared.conf'; import { syntheticMask } from '../fixtures/oct/volumes'; import reference from '../fixtures/oct/reference.json'; @@ -39,36 +40,6 @@ export function stageFixture(name: string) { return STEM + '/' + name; } -export async function openControls() { - const controls = $(CONTROLS); - await controls.waitForStable(); - await controls.waitForClickable(); - if ((await controls.getAttribute('aria-expanded')) !== 'true') - await controls.click(); - await expect(controls).toHaveAttribute('aria-expanded', 'true'); - const panel = $(PANEL); - await panel.waitForDisplayed(); - await panel.waitForStable(); -} - -export async function selectSetting( - testId: string, - title: string, - scope = PANEL -) { - const select = $(scope).$('[data-testid="' + testId + '"]'); - await select.scrollIntoView({ - block: 'center', - inline: 'nearest', - behavior: 'instant', - }); - await select.waitForClickable(); - await select.click(); - const option = overlayOption(title); - await option.waitForClickable(); - await option.click(); -} - export async function expectProjectionPixel(gray: number, x = 60, y = 12) { await expectEnFacePixel(gray, [x, y]); } @@ -149,7 +120,7 @@ export async function openRetina(withMask = false, comparison = false) { comparison ? 'Synthetic retinal layer' : 'Synthetic retinal band' ); } - await openControls(); + await openEnFaceControls(); } export async function expectSliceLine() { diff --git a/tests/specs/octTestUtils.ts b/tests/specs/octTestUtils.ts index 23b3fa904..c1027be08 100644 --- a/tests/specs/octTestUtils.ts +++ b/tests/specs/octTestUtils.ts @@ -9,6 +9,7 @@ import { overlayOption, } from './utils'; import { + createVtiVolume, syntheticOCTGrid, syntheticOCTVolume, syntheticMask, @@ -16,6 +17,7 @@ import { import { openAnnotationSegments, selectSegment, + tooltipOf, waitForSegmentContent, } from './segmentationTestUtils'; @@ -33,26 +35,12 @@ export function stageGenericOCT( const spacing = [...volume.spacing]; spacing[0] *= -1; bytes = Buffer.from( - '' + - '' + - '' + - '' + - Array.from(volume.scalars).join(' ') + - '' + createVtiVolume({ + extent: [0, width - 1, 0, frames - 1, 0, depth - 1], + spacing, + type: 'UInt16', + values: volume.scalars, + }) ); } else bytes = syntheticOCTVolume(format, unitsKnown); fs.writeFileSync(path.join(directory, name), bytes); @@ -153,6 +141,15 @@ export const PANEL = '.v-overlay--active [data-testid="enface-rendering-panel"]'; export const WINDOW_TOOL = 'button[data-testid^="control-button-Window & Level "]'; +export const toolButton = (name: string) => + $('button[data-testid^="control-button-' + name + ' "]'); +/** Display gray for a scalar under window/level, as the slab shader maps it. */ +export const windowedGray = ( + value: number, + { width, level }: { width: number; level: number } +) => Math.round(255 * Math.min(1, Math.max(0, (value - level) / width + 0.5))); +const PROJECTION_TOGGLE = + '.v-overlay--active [data-testid="enface-projection-toggle"]'; const enFaceView = async (view?: WebdriverIO.Element) => view ?? $(VIEWER).getElement(); @@ -186,10 +183,8 @@ export async function displayedWindow(view: WebdriverIO.Element) { return { width: Number(match[1]), level: Number(match[2]) }; } -export async function expandEnFaceSection(section: 'projection') { - const toggle = $( - '.v-overlay--active [data-testid="enface-' + section + '-toggle"]' - ); +export async function expandEnFaceSection() { + const toggle = $(PROJECTION_TOGGLE); await toggle.scrollIntoView({ block: 'center', inline: 'nearest' }); await toggle.waitForClickable(); if ((await toggle.getAttribute('aria-expanded')) !== 'true') @@ -201,6 +196,36 @@ export async function expandEnFaceSection(section: 'projection') { await $(PANEL).waitForStable(); } +export async function openEnFaceControls(view?: WebdriverIO.Element) { + await volViewPage.openMenu((await enFaceView(view)).$(CONTROLS)); + const panel = $(PANEL); + await panel.waitForDisplayed(); + await panel.waitForStable(); +} + +export async function openEnFaceProjection(view?: WebdriverIO.Element) { + await openEnFaceControls(view); + await expandEnFaceSection(); +} + +export async function selectSetting( + testId: string, + title: string, + scope = PANEL +) { + const select = $(scope).$('[data-testid="' + testId + '"]'); + await select.scrollIntoView({ + block: 'center', + inline: 'nearest', + behavior: 'instant', + }); + await select.waitForClickable(); + await select.click(); + const option = overlayOption(title); + await option.waitForClickable(); + await option.click(); +} + export async function chooseEnFaceProjection( testId: string, label: string, @@ -208,21 +233,11 @@ export async function chooseEnFaceProjection( ) { const viewer = await enFaceView(view); await volViewPage.closeMenu($(WINDOW_TOOL)); - const controls = viewer.$(CONTROLS); - await volViewPage.openMenu(controls); - const panel = $(PANEL); - await panel.waitForDisplayed(); - await expandEnFaceSection('projection'); - const select = panel.$('[data-testid="' + testId + '"]'); - await select.scrollIntoView({ block: 'center', inline: 'nearest' }); - await select.waitForClickable(); - await select.click(); - const option = overlayOption(label); - await option.waitForClickable(); - await option.click(); - await option.waitForDisplayed({ reverse: true }); - await volViewPage.closeMenu(controls); - await panel.waitForDisplayed({ reverse: true }); + await openEnFaceProjection(viewer); + await selectSetting(testId, label); + await overlayOption(label).waitForDisplayed({ reverse: true }); + await volViewPage.closeMenu(viewer.$(CONTROLS)); + await $(PANEL).waitForDisplayed({ reverse: true }); } export const slabThumb = (bound: 'start' | 'end') => @@ -237,7 +252,7 @@ export async function expectEnFaceProjectionSettings( start: number, end: number ) { - await expandEnFaceSection('projection'); + await expandEnFaceSection(); await expect( $('.v-overlay--active [data-testid="enface-projection-axis"] input') ).toHaveValue(axis); @@ -282,6 +297,16 @@ export async function setSlabRange(start: number, end: number) { } } +export async function setEnFaceSlab( + start: number, + end: number, + view?: WebdriverIO.Element +) { + await openEnFaceProjection(view); + await setSlabRange(start, end); + await browser.keys('Escape'); +} + export async function dragContrast( canvas: WebdriverIO.Element, dx: number, @@ -436,8 +461,7 @@ export async function expectEnFaceUnavailable(reason: string) { await expect(viewer).toHaveAttribute('aria-busy', 'false'); await expect(viewer.$('[data-testid="enface-slice-lines"]')).not.toExist(); expect((await enFaceAppearance(viewer)).visiblePixels).toBe(0); - await viewer.$(CONTROLS).click(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(viewer); const method = $( '.v-overlay--active [data-testid="enface-projection-method"]' ); @@ -446,7 +470,7 @@ export async function expectEnFaceUnavailable(reason: string) { await browser.keys('Escape'); } -export async function openEnFaceWindowInputs(view?: WebdriverIO.Element) { +async function openEnFaceWindowInputs(view?: WebdriverIO.Element) { const viewer = await enFaceView(view); const tool = $(WINDOW_TOOL); await volViewPage.closeMenu(tool); @@ -498,15 +522,13 @@ export async function setEnFaceWindow( export async function getEnFaceWindow(view?: WebdriverIO.Element) { const viewer = await enFaceView(view); - const toggleSelector = - '.v-overlay--active [data-testid="enface-projection-toggle"]'; const panel = $(PANEL); const controls = viewer.$(CONTROLS); const restorePanel = (await controls.getAttribute('aria-expanded')) === 'true'; const expanded = restorePanel && - (await $(toggleSelector).getAttribute('aria-expanded')) === 'true'; + (await $(PROJECTION_TOGGLE).getAttribute('aria-expanded')) === 'true'; await volViewPage.closeMenu(controls); if (restorePanel) await panel.waitForDisplayed({ reverse: true }); await viewer.$(CANVAS).waitForClickable(); @@ -523,9 +545,9 @@ export async function getEnFaceWindow(view?: WebdriverIO.Element) { const reopened = $(PANEL); await reopened.waitForDisplayed(); await reopened.waitForStable(); - const toggle = $(toggleSelector); + const toggle = $(PROJECTION_TOGGLE); if (expanded) { - await expandEnFaceSection('projection'); + await expandEnFaceSection(); } else if ((await toggle.getAttribute('aria-expanded')) === 'true') { await toggle.scrollIntoView({ block: 'center', inline: 'nearest' }); await toggle.waitForClickable(); @@ -546,9 +568,7 @@ export async function expectEnFaceDisabledInSwitcher(reason: string) { const activator = option.$('..'); await expect(activator).toHaveAttribute('tabindex', '0'); await activator.execute((element) => (element as HTMLElement).focus()); - const tooltipId = await activator.getAttribute('aria-describedby'); - expect(tooltipId).toBeTruthy(); - await expect($(`[id="${tooltipId}"]`)).toHaveText( + await expect(await tooltipOf(activator)).toHaveText( expect.stringContaining(reason) ); } diff --git a/tests/specs/projection-camera.e2e.ts b/tests/specs/projection-camera.e2e.ts index 1cf195685..4e5655e02 100644 --- a/tests/specs/projection-camera.e2e.ts +++ b/tests/specs/projection-camera.e2e.ts @@ -9,7 +9,6 @@ import { volViewPage } from '../pageobjects/volview.page'; import { enFaceAppearance, canvasAppearance, - expandEnFaceSection, expectEnFaceProjectionSettings, expectProjectionTransform, dragContrast, @@ -19,6 +18,9 @@ import { CANVAS, VIEWER, attachAndSelectMask, + openEnFaceProjection, + openEnFaceControls, + selectSetting, } from './octTestUtils'; import { makeTempDir, @@ -27,7 +29,6 @@ import { waitForDownload, writeManifestToFile, } from './utils'; -import { openControls, selectSetting } from './octInteractionTestUtils'; const STEM = 'projection-camera'; const SOURCE_NAME = 'camera-source.nrrd'; @@ -223,8 +224,7 @@ describe('Projection camera persistence', () => { await openCameraScene(); await waitForEnFaceReady(); await attachAndSelectMask(STEM + '/' + MASK_NAME, SEGMENT_NAME); - await openControls(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); await selectSetting('enface-projection-axis', 'Sagittal'); await setSlabRange(10, 100); await waitForEnFaceReady(); @@ -240,7 +240,7 @@ describe('Projection camera persistence', () => { await releaseMaskAfterReachableProjection(inspected); await expectSourceMaskPixels(sourceMaskImage); await expectCameraAndPixels(inspected); - await openControls(); + await openEnFaceControls(); await expectEnFaceProjectionSettings('Sagittal', 10, 100); expect(await volViewPage.getNotificationsCount()).toBe(0); }); diff --git a/tests/specs/projection-contrast.e2e.ts b/tests/specs/projection-contrast.e2e.ts index 69d00349c..e6d8cb91e 100644 --- a/tests/specs/projection-contrast.e2e.ts +++ b/tests/specs/projection-contrast.e2e.ts @@ -14,11 +14,11 @@ import { volViewPage } from '../pageobjects/volview.page'; import { chooseEnFaceProjection, expectEnFacePixel, - expandEnFaceSection, getEnFaceWindow, setEnFaceWindow, - setSlabRange, waitForEnFaceReady, + setEnFaceSlab, + windowedGray, } from './octTestUtils'; import { makeTempDir, @@ -67,14 +67,6 @@ async function expectVisibleContrast(minimumSpread: number) { }); } -async function setProjectionSlab(first: number, last: number) { - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(first, last); - await browser.keys('Escape'); - await waitForEnFaceReady(); -} - async function expectAutomaticRange(minimum: number, maximum: number) { const window = await getEnFaceWindow(); expect(window.width).toBeGreaterThan(0); @@ -88,10 +80,7 @@ async function expectAnalyticalPixel( value: number, window: { width: number; level: number } ) { - const gray = Math.round( - ((value - window.level + window.width / 2) / window.width) * 255 - ); - await expectEnFacePixel(gray, [4, 3]); + await expectEnFacePixel(windowedGray(value, window), [4, 3]); } async function enFaceImageBounds() { @@ -302,7 +291,8 @@ describe('Projection default contrast', () => { await waitForEnFaceReady(); await expectAnalyticalPixel(1551, await expectAutomaticRange(1110, 1875)); await chooseEnFaceProjection('enface-projection-method', 'Mean'); - await setProjectionSlab(1, 3); + await setEnFaceSlab(1, 3); + await waitForEnFaceReady(); await expectAnalyticalPixel(89, await expectAutomaticRange(40, 125)); const manual = { width: 160, level: 80 }; @@ -313,7 +303,8 @@ describe('Projection default contrast', () => { expect(await getEnFaceWindow()).toEqual(automatic); await expectAnalyticalPixel(109, automatic); await setEnFaceWindow(manual.width, manual.level); - await setProjectionSlab(2, 4); + await setEnFaceSlab(2, 4); + await waitForEnFaceReady(); expect(await getEnFaceWindow()).toEqual(manual); await expectAnalyticalPixel(129, manual); const saved = await volViewPage.saveSession(); diff --git a/tests/specs/projection-display-controls.e2e.ts b/tests/specs/projection-display-controls.e2e.ts index 4dc43ebde..2ddcf2f00 100644 --- a/tests/specs/projection-display-controls.e2e.ts +++ b/tests/specs/projection-display-controls.e2e.ts @@ -19,6 +19,11 @@ import { CANVAS, VIEWER, PANEL, + openEnFaceProjection, + openEnFaceControls, + setEnFaceSlab, + selectSetting, + windowedGray, } from './octTestUtils'; import { stageProjectionContrastVolume } from './projectionTestUtils'; import { @@ -34,7 +39,6 @@ import { waitForModuleTransitions, writeManifestToFile, } from './utils'; -import { selectSetting } from './octInteractionTestUtils'; const STEM = 'projection-display-controls'; const SIDEBAR = '[data-testid="enface-sidebar-controls"]'; @@ -92,13 +96,7 @@ async function expectProjectionGray( inverted = false, projectedValue = 1551 / 9 ) { - const value = Math.min( - 255, - Math.max( - 0, - Math.round(((projectedValue - window.level) / window.width + 0.5) * 255) - ) - ); + const value = windowedGray(projectedValue, window); const expected = inverted ? 255 - value : value; await expectEnFacePixel(expected, [4, 3], view); } @@ -247,8 +245,7 @@ describe('Projection Layers display controls', () => { await setLayersRange(0, 1000); await setValueVueInput(field('max'), '-1'); await expect($('[data-testid="base-image-range-status"]')).toBeDisplayed(); - await target.$('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(target); await setSlabRange(1, 3); expect(await getEnFaceWindow(target)).toEqual({ width: 1000, level: 500 }); await expect($(PANEL).$('button*=Reset contrast')).not.toExist(); @@ -260,10 +257,7 @@ describe('Projection Layers display controls', () => { const slabWindow = await expectLayersWindow(target); expect(slabWindow).toEqual({ width: 425, level: 412.5 }); await expectProjectionGray(target, slabWindow, true, 445); - await target.$('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(0, 4); - await browser.keys('Escape'); + await setEnFaceSlab(0, 4, target); expect(await expectLayersWindow(target)).toEqual(automaticSum); await expectProjectionGray(target, automaticSum, true, 1551); await expect($('[data-testid="base-image-colormap"]')).toHaveText( @@ -335,13 +329,12 @@ describe('Projection Layers display controls', () => { async () => (await enFaceAppearance(target)).image !== meanImage ); expect((await enFaceAppearance(peer)).image).toBe(peerImage); - await target.$('[data-testid="enface-controls"]').click(); - await $(PANEL).waitForDisplayed(); + await openEnFaceControls(target); await expect( $(PANEL).$('[data-testid="enface-projection-method"] input') ).toHaveValue('Maximum'); await selectSetting('enface-projection-axis', 'Axial'); - await expandEnFaceSection('projection'); + await expandEnFaceSection(); await setSlabRange(1, 3); await browser.keys('Escape'); await $(PANEL).waitForDisplayed({ reverse: true }); @@ -415,8 +408,7 @@ describe('Projection Layers display controls', () => { $(SIDEBAR).$('[data-testid="enface-projection-method"] input') ).toHaveValue('Sum'); expect(await appliedWindow()).toEqual(peerWindow); - await peer.$('[data-testid="enface-controls"]').click(); - await $(PANEL).waitForDisplayed(); + await openEnFaceControls(peer); await expect( $(PANEL).$('[data-testid="enface-projection-method"] input') ).toHaveValue('Sum'); diff --git a/tests/specs/projection-lifecycle.e2e.ts b/tests/specs/projection-lifecycle.e2e.ts index 57370b762..6284b8273 100644 --- a/tests/specs/projection-lifecycle.e2e.ts +++ b/tests/specs/projection-lifecycle.e2e.ts @@ -8,7 +8,6 @@ import { canvasAppearance, chooseEnFaceProjection, displayedWindow, - expandEnFaceSection, getEnFaceWindow, projectionTransform, setSlabRange, @@ -17,6 +16,7 @@ import { CANVAS, VIEWER, WINDOW_TOOL, + openEnFaceProjection, } from './octTestUtils'; import { expectProjectionPixel } from './octInteractionTestUtils'; @@ -72,8 +72,7 @@ describe('Projection lifecycle', () => { expect(await getEnFaceWindow()).toEqual({ width: 192, level: 331 }); await expectProjectionPixel(109); await setEnFaceWindow(1000, 500); - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); for (const [first, last] of [ [10, 50], [100, 103], diff --git a/tests/specs/slab-crosshairs.e2e.ts b/tests/specs/slab-crosshairs.e2e.ts index 0415d5b5e..bc55ba025 100644 --- a/tests/specs/slab-crosshairs.e2e.ts +++ b/tests/specs/slab-crosshairs.e2e.ts @@ -8,13 +8,14 @@ import { chooseEnFaceProjection, dragContrast, enFaceScreenPoint, - expandEnFaceSection, projectionTransform, setEnFaceWindow, - setSlabRange, slabThumb, waitForEnFaceReady, CANVAS, + openEnFaceProjection, + setEnFaceSlab, + toolButton, } from './octTestUtils'; import { makeTempDir, @@ -66,9 +67,6 @@ async function scene(otherImage = false) { await browser.waitUntil(async () => Object.keys(await slices()).length === 3); } -const tool = (name: string) => - $('button[data-testid^="control-button-' + name + ' "]'); - function slices() { return browser.execute(() => Object.fromEntries( @@ -111,11 +109,6 @@ async function expectSlices(expected: Record) { ); } -async function controls() { - await $(VIEW).$('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); -} - function gapWidths() { return browser.execute(() => { const svg = document.querySelector( @@ -178,16 +171,14 @@ describe('Crosshairs in slab projections', () => { 'enface-projection-axis', ORIENTATIONS[axis] ); - await controls(); const last = 4; - await setSlabRange(1, last); - await browser.keys('Escape'); + await setEnFaceSlab(1, last); await waitForEnFaceReady(); const before = await slices(); const framing = await projectionTransform(); await $(VIEW).click(); - await expect(tool('Crosshairs')).toBeEnabled(); - await tool('Crosshairs').click(); + await expect(toolButton('Crosshairs')).toBeEnabled(); + await toolButton('Crosshairs').click(); const inPlane = ORIENTATIONS.filter((_, index) => index !== axis); const expected = { ...before, [inPlane[0]]: 2, [inPlane[1]]: 3 }; await pick(2, 3); @@ -195,7 +186,7 @@ describe('Crosshairs in slab projections', () => { await pick(4, 2, [2, 3]); await expectSlices({ ...before, [inPlane[0]]: 4, [inPlane[1]]: 2 }); expect(await projectionTransform()).toEqual(framing); - await controls(); + await openEnFaceProjection(); await expect(slabThumb('start')).toHaveAttribute('aria-valuenow', '1'); await expect(slabThumb('end')).toHaveAttribute( 'aria-valuenow', @@ -210,7 +201,7 @@ describe('Crosshairs in slab projections', () => { await scene(); await expectGap(); await $(VIEW).click(); - await tool('Crosshairs').click(); + await toolButton('Crosshairs').click(); await $( '.vtk-container-wrapper [data-testid="reference-lines"] line' ).waitForExist(); @@ -237,7 +228,7 @@ describe('Crosshairs in slab projections', () => { ['Pan', 18, 12], ] as const) { const before = await projectionTransform(); - await tool(name).click(); + await toolButton(name).click(); await dragContrast(await $(CANVAS).getElement(), dx, dy); await browser.waitUntil(async () => (await projectionTransform()).some( @@ -260,7 +251,7 @@ describe('Crosshairs in slab projections', () => { ) ); await expectGap(); - await tool('Crosshairs').click(); + await toolButton('Crosshairs').click(); await pick(3, 4); await expectSlices({ ...sourceSlices, Coronal: 3, Sagittal: 4 }); } finally { @@ -300,19 +291,23 @@ describe('Crosshairs in slab projections', () => { await $('[data-testid="base-image-visibility"]').click(); await setEnFaceWindow(600, 300, await $(VIEW).getElement()); await expect($(VIEW)).toHaveAttribute('aria-busy', 'false'); - await tool('Pan').click(); + await toolButton('Pan').click(); const keyboard = () => browser.action('key', { id: 'slab-crosshair-hold' }); try { await keyboard().down(Key.Shift).down('c').perform(true); - await expect(tool('Crosshairs')).toHaveElementClass('tool-btn-selected'); + await expect(toolButton('Crosshairs')).toHaveElementClass( + 'tool-btn-selected' + ); await browser .action('pointer') .move(await point(3, 2)) .perform(true); await expectSlices({ ...before, Sagittal: 2 }); - await expect(tool('Crosshairs')).toHaveElementClass('tool-btn-selected'); + await expect(toolButton('Crosshairs')).toHaveElementClass( + 'tool-btn-selected' + ); await keyboard().up('c').up(Key.Shift).perform(true); - await expect(tool('Pan')).toHaveElementClass('tool-btn-selected'); + await expect(toolButton('Pan')).toHaveElementClass('tool-btn-selected'); } finally { await browser.releaseActions(); } diff --git a/tests/specs/slab-geometry.e2e.ts b/tests/specs/slab-geometry.e2e.ts index 4ff40e287..6fac2d07c 100644 --- a/tests/specs/slab-geometry.e2e.ts +++ b/tests/specs/slab-geometry.e2e.ts @@ -2,18 +2,17 @@ import fs from 'node:fs'; import path from 'node:path'; import { useReferenceLines } from './referenceLinesTestUtils'; import { TEMP_DIR } from '../../wdio.shared.conf'; -import { createNrrdVolume } from '../fixtures/oct/volumes'; +import { createNrrdVolume, createVtiVolume } from '../fixtures/oct/volumes'; import { volViewPage } from '../pageobjects/volview.page'; import { chooseEnFaceProjection, canvasAppearance, enFaceAppearance, enFacePixel, - expandEnFaceSection, setEnFaceWindow, - setSlabRange, waitForEnFaceReady, CANVAS, + setEnFaceSlab, } from './octTestUtils'; import { makeTempDir, @@ -32,9 +31,10 @@ const valueAt = (x: number, y: number, z: number) => 17 * x + 11 * y + 7 * z; function vti(offsetExtent: boolean) { const minimum = offsetExtent ? MINIMUM : [0, 0, 0]; const origin = offsetExtent ? [0, 0, 0] : MINIMUM; - const extent = minimum - .flatMap((first, axis) => [first, first + SIZE[axis] - 1]) - .join(' '); + const extent = minimum.flatMap((first, axis) => [ + first, + first + SIZE[axis] - 1, + ]); const values = Array.from({ length: SIZE[0] * SIZE[1] * SIZE[2] }, (_, i) => valueAt( i % SIZE[0], @@ -42,14 +42,7 @@ function vti(offsetExtent: boolean) { Math.floor(i / (SIZE[0] * SIZE[1])) ) ); - return ( - '' + - `` + - `` + - '' + - values.join(' ') + - '' - ); + return createVtiVolume({ extent, origin, type: 'Float32', values }); } function signedFitVolume(negativeAxis?: 0 | 2) { @@ -70,14 +63,13 @@ function signedFitVolume(negativeAxis?: 0 | 2) { if (x < 2 || x >= 18) return 255; return x === 2 || x === 17 ? 0 : 70; }); - return ( - '' + - `` + - '' + - '' + - values.join(' ') + - '' - ); + return createVtiVolume({ + extent: [0, 19, 0, 9, 0, 9], + spacing, + direction, + type: 'UInt8', + values, + }); } async function expectEdgeFiducials(selector: string) { @@ -173,10 +165,7 @@ describe('Slab geometry and automatic camera fitting', () => { await openScene(name); await chooseEnFaceProjection('enface-projection-axis', AXES[axis]); await setEnFaceWindow(200, 100); - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(1, SIZE[axis] - 2); - await browser.keys('Escape'); + await setEnFaceSlab(1, SIZE[axis] - 2); await waitForEnFaceReady(); const point = [1, 1]; const index = [1, 1, 1]; diff --git a/tests/specs/slab-orientation.e2e.ts b/tests/specs/slab-orientation.e2e.ts index f8fe33a5d..6b47859c3 100644 --- a/tests/specs/slab-orientation.e2e.ts +++ b/tests/specs/slab-orientation.e2e.ts @@ -4,11 +4,10 @@ import { createNrrdVolume } from '../fixtures/oct/volumes'; import { volViewPage } from '../pageobjects/volview.page'; import { chooseEnFaceProjection, - expandEnFaceSection, setEnFaceWindow, - setSlabRange, waitForEnFaceReady, VIEWER, + setEnFaceSlab, } from './octTestUtils'; import { makeTempDir, writeManifestToFile } from './utils'; @@ -155,13 +154,6 @@ async function expectMatchingOrientation() { ); } -async function setDepth(depth: number) { - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(depth, depth); - await browser.keys('Escape'); -} - describe('Slab orientation follows ordinary volume views', () => { for (const frame of frames) { for (const axis of [0, 1, 2]) { @@ -172,10 +164,10 @@ describe('Slab orientation follows ordinary volume views', () => { frame.axes[axis] ); await setEnFaceWindow(220, 130); - await setDepth(48); + await setEnFaceSlab(48, 48); await expectMatchingOrientation(); for (const depth of [95, 0]) { - await setDepth(depth); + await setEnFaceSlab(depth, depth); await expectMatchingOrientation(); } expect(await volViewPage.getNotificationsCount()).toBe(0); diff --git a/tests/specs/slab-projection.e2e.ts b/tests/specs/slab-projection.e2e.ts index e09c351f9..df144af21 100644 --- a/tests/specs/slab-projection.e2e.ts +++ b/tests/specs/slab-projection.e2e.ts @@ -12,7 +12,6 @@ import { enFaceAppearance, expectEnFacePixel, expectProjectionTransform, - expandEnFaceSection, getEnFaceWindow, projectionTransform, setSlabRange, @@ -22,6 +21,8 @@ import { CANVAS, VIEWER, PANEL, + openEnFaceProjection, + windowedGray, } from './octTestUtils'; import { makeTempDir, @@ -79,17 +80,7 @@ async function openScene(twoProjections = false, nonlinear = false) { async function openControls(view: WebdriverIO.Element) { await browser.keys('Escape'); - await view.$('[data-testid="enface-controls"]').click(); - await $(PANEL).waitForDisplayed(); - await expandEnFaceSection('projection'); -} - -async function setWindow( - view: WebdriverIO.Element, - width: number, - level: number -) { - await setEnFaceWindow(width, level, view); + await openEnFaceProjection(view); } async function magnifyProjection(view: WebdriverIO.Element) { @@ -124,10 +115,7 @@ async function expectGray( value: number, contrast = { width: 2048, level: 1024 } ) { - const expected = Math.round( - 255 * - Math.max(0, Math.min(1, (value - contrast.level) / contrast.width + 0.5)) - ); + const expected = windowedGray(value, contrast); expect(expected).toBeGreaterThan(0); expect(expected).toBeLessThan(255); await expectEnFacePixel(expected, point, view); @@ -270,7 +258,7 @@ describe('Generic slab projection contracts', () => { level: (minimum + maximum) / 2, }; await chooseEnFaceProjection('enface-projection-method', method, view); - await setWindow(view, contrast.width, contrast.level); + await setEnFaceWindow(contrast.width, contrast.level, view); await openControls(view); for (const { first, last, expectedValues } of cases) { await setSlabRange(first, last); @@ -321,7 +309,7 @@ describe('Generic slab projection contracts', () => { await openControls(view); await setSlabRange(setting.first, setting.last); await browser.keys('Escape'); - await setWindow(view, setting.width, setting.level); + await setEnFaceWindow(setting.width, setting.level, view); } const expected = []; for (const [index, view] of views.entries()) { @@ -388,7 +376,7 @@ describe('Generic slab projection contracts', () => { AXES[setting.axis], view ); - await setWindow(view, setting.width, setting.level); + await setEnFaceWindow(setting.width, setting.level, view); await view.$(CANVAS).click(); await $('button[data-testid^="control-button-Pan "]').click(); await dragContrast(await view.$(CANVAS).getElement(), 12 + index * 8, 9); @@ -414,7 +402,7 @@ describe('Generic slab projection contracts', () => { const restored = await saveAndReopen(); for (const [index, view] of restored.entries()) await expectSnapshot(view, expected[index]); - await setWindow(restored[0], 600, 200); + await setEnFaceWindow(600, 200, restored[0]); await expectSourceAndPeerUnchanged( sourceContrast, restored[1], @@ -429,7 +417,7 @@ describe('Generic slab projection contracts', () => { width: 134, level: 1750 / 13, }); - await setWindow(manual, 350, 175); + await setEnFaceWindow(350, 175, manual); const unchanged = await snapshot(manual); await chooseEnFaceProjection( 'enface-projection-method', @@ -488,7 +476,7 @@ describe('Generic slab projection contracts', () => { const fullRestored = await saveAndReopen(); await expectSnapshot(fullRestored[0], savedFull); await expectSnapshot(fullRestored[1], unchanged); - await setWindow(fullRestored[0], 600, 200); + await setEnFaceWindow(600, 200, fullRestored[0]); await chooseEnFaceProjection( 'enface-projection-method', 'Sum', diff --git a/tests/specs/slab-reference-visibility.e2e.ts b/tests/specs/slab-reference-visibility.e2e.ts index a2be4df19..1bab872c2 100644 --- a/tests/specs/slab-reference-visibility.e2e.ts +++ b/tests/specs/slab-reference-visibility.e2e.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { createNrrdVolume } from '../fixtures/oct/volumes'; import { volViewPage } from '../pageobjects/volview.page'; -import { waitForEnFaceReady, VIEWER } from './octTestUtils'; +import { waitForEnFaceReady, VIEWER, toolButton } from './octTestUtils'; import { setReferenceLinesEnabled } from './referenceLinesTestUtils'; import { makeTempDir, @@ -12,9 +12,6 @@ import { const STEM = 'slab-reference-visibility'; const SOURCE = 'div[data-testid~="vtk-two-view"]'; -const tool = (name: string) => - $('button[data-testid^="control-button-' + name + ' "]'); - async function expectReferenceLines(visible: boolean) { const lines = [ $(VIEWER).$('[data-testid="enface-slice-lines"] line'), @@ -45,7 +42,7 @@ describe('Slab reference-line visibility', () => { await volViewPage.open(`?urls=[tmp/${STEM}.json,tmp/${STEM}/source.nrrd]`); await waitForEnFaceReady(); await $(VIEWER).$('canvas').click(); - await tool('Pan').click(); + await toolButton('Pan').click(); await setReferenceLinesEnabled(false); await expectReferenceLines(false); @@ -61,9 +58,9 @@ describe('Slab reference-line visibility', () => { await setReferenceLinesEnabled(false); await expectReferenceLines(false); await $(SOURCE).$('canvas').click(); - await tool('Crosshairs').click(); + await toolButton('Crosshairs').click(); await expectReferenceLines(true); - await tool('Pan').click(); + await toolButton('Pan').click(); await expectReferenceLines(false); }); }); diff --git a/tests/specs/slab-volume-inputs.e2e.ts b/tests/specs/slab-volume-inputs.e2e.ts index f4c5fded6..4ec791286 100644 --- a/tests/specs/slab-volume-inputs.e2e.ts +++ b/tests/specs/slab-volume-inputs.e2e.ts @@ -13,13 +13,13 @@ import { makeTempDir, useCachedSample, writeManifestToFile } from './utils'; import { chooseEnFaceProjection, enFaceAppearance, - expandEnFaceSection, expectEnFaceUnavailable, selectEnFace, setEnFaceWindow, - setSlabRange, waitForEnFaceReady, + setEnFaceSlab, } from './octTestUtils'; +import { createVtiVolume } from '../fixtures/oct/volumes'; const STEM = 'slab-volume-inputs'; @@ -42,15 +42,15 @@ async function expectMethodChangesImage() { } function stageColorVolume() { - const values = Array.from({ length: 64 }, () => '200 30 20').join(' '); fs.writeFileSync( path.join(makeTempDir(STEM), 'color.vti'), - '' + - '' + - '' + - '' + - values + - '' + createVtiVolume({ + extent: [0, 3, 0, 3, 0, 3], + type: 'UInt8', + name: 'color', + components: 3, + values: Array.from({ length: 64 }, () => [200, 30, 20]).flat(), + }) ); return `${STEM}/color.vti`; } @@ -80,10 +80,7 @@ describe('Scalar volume projection inputs', () => { await waitForEnFaceReady(); await chooseEnFaceProjection('enface-projection-axis', 'Axial'); await setEnFaceWindow(128, 64); - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(10, 60); - await browser.keys('Escape'); + await setEnFaceSlab(10, 60); await expectMethodChangesImage(); }); diff --git a/tests/specs/stock-reslice-slabs.e2e.ts b/tests/specs/stock-reslice-slabs.e2e.ts index 097e875c2..ea8b2a2f2 100644 --- a/tests/specs/stock-reslice-slabs.e2e.ts +++ b/tests/specs/stock-reslice-slabs.e2e.ts @@ -8,13 +8,13 @@ import { chooseEnFaceProjection, displayedWindow, expectEnFacePixel, - expandEnFaceSection, getEnFaceWindow, setEnFaceWindow, - setSlabRange, slabThumb, waitForEnFaceReady, VIEWER, + openEnFaceProjection, + setEnFaceSlab, } from './octTestUtils'; import { useReferenceLines } from './referenceLinesTestUtils'; import { @@ -114,10 +114,7 @@ describe('Built-in reslice slab rendering', () => { const source = await $('[data-testid~="vtk-two-view"]').getElement(); const sourceWindow = await displayedWindow(source); await observeProjectionRedraw(view); - await view.$('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(scenario.first, scenario.last); - await browser.keys('Escape'); + await setEnFaceSlab(scenario.first, scenario.last, view); for (const [index, method] of ['Mean', 'Maximum', 'Sum'].entries()) { await chooseEnFaceProjection('enface-projection-method', method, view); await setEnFaceWindow(WINDOW.width, WINDOW.level, view); @@ -145,18 +142,14 @@ describe('Built-in reslice slab rendering', () => { await volViewPage.open(`?urls=[tmp/${STEM}/oversized.volview.zip]`); await waitForEnFaceReady(); await expectIntensity(400); - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); + await openEnFaceProjection(); await expect(slabThumb('end')).toHaveAttribute('aria-valuenow', '14'); await browser.keys('Escape'); const clamped = await saveArchive(); expect(savedSlabs(clamped.manifest)).toMatchObject([ { depthStart: 0, depthEnd: 14 }, ]); - await $('[data-testid="enface-controls"]').click(); - await expandEnFaceSection('projection'); - await setSlabRange(10, 14); - await browser.keys('Escape'); + await setEnFaceSlab(10, 14); await waitForEnFaceReady(); await expectIntensity(400); expect(await getEnFaceWindow()).toEqual(WINDOW); diff --git a/tests/specs/syntheticDicom.ts b/tests/specs/syntheticDicom.ts index 205da1c0c..eb9454a0d 100644 --- a/tests/specs/syntheticDicom.ts +++ b/tests/specs/syntheticDicom.ts @@ -58,7 +58,20 @@ export const elemShort = ( value ); -// Long-form explicit VR (2-byte reserved + 4-byte length): OB, OW, UN, SQ, ... +export const LONG_FORM_VRS = new Set([ + 'OB', + 'OD', + 'OF', + 'OL', + 'OW', + 'SQ', + 'UC', + 'UN', + 'UR', + 'UT', +]); + +// Long-form explicit VR (2-byte reserved + 4-byte length): LONG_FORM_VRS. export const elemLong = ( group: number, element: number, diff --git a/tests/unit/octFixture.ts b/tests/unit/octFixture.ts index ae56963fb..1e818edb7 100644 --- a/tests/unit/octFixture.ts +++ b/tests/unit/octFixture.ts @@ -4,8 +4,15 @@ import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; import { DicomFileMetaLoader } from '@/src/core/streaming/dicom/dicomFileMetaLoader'; import { DicomFileDataLoader } from '@/src/core/streaming/dicom/dicomFileDataLoader'; import { Tags, toDicomParserKey } from '@/src/core/dicomTags'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useViewStore } from '@/src/store/views'; +import { untilLoaded } from '@/src/composables/untilLoaded'; +import type { Maybe } from '@/src/types'; -import { createSyntheticOCT } from '../fixtures/oct/dicom'; +import { + createSyntheticOCT, + overwriteDicomString, +} from '../fixtures/oct/dicom'; /** Reads the named VolView tags, standing in for the itk-wasm tag reader. */ export async function readDicomTags(file: Blob) { @@ -32,13 +39,11 @@ export function createOCTFixture( const bytes = Uint8Array.from(createSyntheticOCT()); if (options.scanPattern) { const data = dicomParser.parseDicom(bytes, { untilTag: 'x7fe00010' }); - const pattern = - data.elements.x00221618.items![0].dataSet!.elements.x00080100; - const replacement = new TextEncoder().encode(options.scanPattern); - if (replacement.length > pattern.length) - throw new Error('Fixture scan code is too long'); - bytes.fill(0x20, pattern.dataOffset, pattern.dataOffset + pattern.length); - bytes.set(replacement, pattern.dataOffset); + overwriteDicomString( + data.elements.x00221618.items![0].dataSet!, + 'x00080100', + options.scanPattern + ); } const file = new File([bytes], 'synthetic-oct.dcm'); const chunk = new Chunk({ @@ -81,3 +86,33 @@ export function createOCTFixture( }); return { image, chunk, releaseDecode }; } + +/** Replaces the first layout view with an active En face view of `dataID`. */ +export function activateEnFace(dataID: Maybe) { + const views = useViewStore(); + views.replaceView(views.layoutViews[0].id, { + name: 'En face', + type: 'EnFace', + dataID, + options: {}, + }); + const viewID = views.layoutViews[0].id; + views.setActiveView(viewID); + return viewID; +} + +/** Caches and loads a synthetic OCT; `releases` collects its decode gate for cleanup. */ +export async function loadOCT( + releases: (() => void)[], + id = 'retina-oct', + options: Parameters[0] = {} +) { + const fixture = createOCTFixture(options); + releases.push(fixture.releaseDecode); + useImageCacheStore().addProgressiveImage(fixture.image, { id }); + await fixture.chunk.loadMeta(); + await fixture.image.addChunks([fixture.chunk]); + fixture.image.startLoad(); + if (!options.deferDecode) await untilLoaded(id); + return id; +} From 0ce753660435df82b99d6c74c2d7ac8dc1c3cd5b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 1 Nov 2026 16:37:30 -0500 Subject: [PATCH 4/5] refactor: assume zero-based grids in en face Only VTI can describe a grid whose voxel indices start at an offset, and en face does not need to support one. Drop the code that handled it: the extent offset in the vtk.js reslice mapper patch, extent-based 2D slice ranges, the extent minimum in the slab plane and reference line geometry, and the crosshairs refusal for offset grids, along with their tests. --- patches/@kitware+vtk.js+38.0.4.patch | 26 ++----- src/core/slab/__tests__/geometry.spec.ts | 6 +- src/core/slab/geometry.ts | 9 +-- src/enface/EnFaceViewer.vue | 6 +- src/enface/__tests__/sliceGeometry.spec.ts | 17 +--- src/enface/__tests__/viewer.spec.ts | 13 ---- src/enface/sliceGeometry.ts | 14 +--- .../__tests__/enFaceCapabilities.spec.ts | 36 --------- src/store/tools/crosshairs.ts | 21 +---- src/store/tools/index.ts | 9 +-- src/store/view-configs/slicing.ts | 11 ++- tests/fixtures/oct/volumes.ts | 8 +- tests/specs/octTestUtils.ts | 3 +- tests/specs/slab-geometry.e2e.ts | 78 +------------------ tests/specs/slab-volume-inputs.e2e.ts | 2 +- 15 files changed, 30 insertions(+), 229 deletions(-) diff --git a/patches/@kitware+vtk.js+38.0.4.patch b/patches/@kitware+vtk.js+38.0.4.patch index 77bfe8b00..c352c5755 100644 --- a/patches/@kitware+vtk.js+38.0.4.patch +++ b/patches/@kitware+vtk.js+38.0.4.patch @@ -1,23 +1,7 @@ diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js b/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js --- a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js +++ b/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js -@@ -440,11 +440,12 @@ - if (program.isUniformUsed(uniformName)) { - const imageData = model.currentValidInputs[i].imageData; - const dim = imageData.getDimensions(); -+ const extent = imageData.getExtent(); - mat4.copy(model.tmpMat4, imageData.getIndexToWorld()); - mat4.translate(model.tmpMat4, model.tmpMat4, [ -- -.5, -- -.5, -- -.5 -+ extent[0] - .5, -+ extent[2] - .5, -+ extent[4] - .5 - ]); - mat4.scale(model.tmpMat4, model.tmpMat4, dim); - mat4.invert(model.tmpMat4, model.tmpMat4); -@@ -574,7 +575,7 @@ +@@ -574,7 +574,7 @@ let needRebuild = false; if (!model.currentRenderPass && model.lastRenderPassShaderReplacement || model.currentRenderPass && model.currentRenderPass.getShaderReplacement() !== model.lastRenderPassShaderReplacement) needRebuild = true; const numValidInputs = model.currentValidInputs?.length ?? 0; @@ -26,7 +10,7 @@ diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js model.lastHaveSeenDepthRequest = model.haveSeenDepthRequest; model.lastNumberOfComponents = model.numberOfComponents; model.lastMultiTexturePerVolumeEnabled = model.multiTexturePerVolumeEnabled; -@@ -724,33 +725,25 @@ +@@ -724,33 +724,25 @@ } function getSlabSampleLoopLines() { return [ @@ -74,7 +58,7 @@ diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js " vec3 fragTCoordNeg = (WCTCMatrix0 * vec4(worldPosNeg, 1.0)).xyz;", " if (!any(greaterThan(fragTCoordNeg, vec3(1.0))) && !any(lessThan(fragTCoordNeg, vec3(0.0))))", " {", -@@ -758,11 +751,11 @@ +@@ -758,11 +750,11 @@ " tvalue = compositeValue(tvalue, newVal, trapezoid);", " numSlices += 1;", " }", @@ -88,7 +72,7 @@ diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js " vec3 fragTCoordPos = (WCTCMatrix0 * vec4(worldPosPos, 1.0)).xyz;", " if (!any(greaterThan(fragTCoordPos, vec3(1.0))) && !any(lessThan(fragTCoordPos, vec3(0.0))))", " {", -@@ -770,11 +763,15 @@ +@@ -770,11 +762,15 @@ " tvalue = compositeValue(tvalue, newVal, trapezoid);", " numSlices += 1;", " }", @@ -106,7 +90,7 @@ diff --git a/node_modules/@kitware/vtk.js/Rendering/OpenGL/ImageResliceMapper.js "}", "// Finally, if slab type is *mean*, divide the sum by the numSlices", "if (slabType == 2)", -@@ -1155,7 +1152,7 @@ +@@ -1155,7 +1151,7 @@ let FSSource = shaders.Fragment; const slabThickness = model.renderable.getSlabThickness(); let posVCVSDec = ["attribute vec4 vertexWC;", "varying vec4 vertexWCVSOutput;"]; diff --git a/src/core/slab/__tests__/geometry.spec.ts b/src/core/slab/__tests__/geometry.spec.ts index 0abb523e0..3ccaf0051 100644 --- a/src/core/slab/__tests__/geometry.spec.ts +++ b/src/core/slab/__tests__/geometry.spec.ts @@ -11,7 +11,7 @@ import { function source() { const image = vtkImageData.newInstance(); - image.setExtent([5, 7, -4, -1, 10, 14]); + image.setDimensions(3, 4, 5); image.setOrigin([23, -8, 100]); image.setSpacing([-0.2, 0.7, 2]); const angle = 0.4; @@ -46,7 +46,7 @@ describe('slab source-world geometry', () => { last: 2, method: 'sum', }); - const sourceIndex: Vector3 = [6, -3, 11]; + const sourceIndex: Vector3 = [1, 1, 1]; const world = image.indexToWorld(sourceIndex); expectPoint( vec3.transformMat4([0, 0, 0], [1.5, 1.5, 0], geometry.pixelToWorld), @@ -101,7 +101,7 @@ describe('slab source-world geometry', () => { expect(vec3.distance(first.planeOrigin, last.planeOrigin)).toBeCloseTo(400); expectPoint( vec3.transformMat4([0, 0, 0], [0.5, 0.5, 0], last.pixelToWorld), - image.indexToWorld([5, -4, 14]) + image.indexToWorld([0, 0, 4]) ); image.delete(); }); diff --git a/src/core/slab/geometry.ts b/src/core/slab/geometry.ts index b2e844b3d..5aa8830cd 100644 --- a/src/core/slab/geometry.ts +++ b/src/core/slab/geometry.ts @@ -21,11 +21,8 @@ export function deriveSlabGeometry( spec: SlabSpecification ) { const axes = [0, 1, 2].filter((axis) => axis !== spec.axis); - const extent = image.getExtent(); - const minimum = [extent[0], extent[2], extent[4]]; - const midpoint = minimum[spec.axis] + (spec.first + spec.last) / 2; - const planeIndex = [...minimum] as Vector3; - planeIndex[spec.axis] = midpoint; + const planeIndex: Vector3 = [0, 0, 0]; + planeIndex[spec.axis] = (spec.first + spec.last) / 2; const planeOrigin = image.indexToWorld(planeIndex) as Vector3; const planeNormal = vec3.normalize( [0, 0, 0], @@ -53,14 +50,12 @@ export function deriveSlabGeometry( return { metadata, pixelToWorld, - sourceExtent: extent, planeOrigin, planeNormal, slabThickness: slabThickness(image, spec), fitKey: [ spec.axis, ...metadata.dimensions, - ...extent, ...metadata.spacing, ...metadata.origin, ...metadata.orientation, diff --git a/src/enface/EnFaceViewer.vue b/src/enface/EnFaceViewer.vue index ce9af1ab3..3dd82e300 100644 --- a/src/enface/EnFaceViewer.vue +++ b/src/enface/EnFaceViewer.vue @@ -11,7 +11,6 @@ import VtkSlabProjectionRepresentation from '@/src/components/vtk/VtkSlabProject import CrosshairsWidget from '@/src/components/tools/crosshairs/CrosshairsWidget.vue'; import { ReferenceLineSegments } from '@/src/referenceLines'; import { useToolStore } from '@/src/store/tools'; -import { getEnFaceCrosshairsUnavailableReason } from '@/src/store/tools/crosshairs'; import { Tools } from '@/src/store/tools/types'; import { useResetViewsEvents } from '@/src/components/tools/ResetViews.vue'; import type { VtkViewApi } from '@/src/types/vtk-types'; @@ -137,10 +136,7 @@ watch( { ) ).toBeNull(); }); - it('clips nonzero source extents and maps intersections to projection pixels', () => { - const grid = metadata(true, true); - const line = enFaceSliceLine( - projection(grid, 1, 9), - slicePlane('Axial', 27, grid), - grid - )!; - expect(line.y1).toBeCloseTo(7.5, 4); - expect(line.y2).toBeCloseTo(7.5, 4); - expect([line.x1, line.x2].sort((a, b) => a - b)).toEqual([ - expect.closeTo(0, 4), - expect.closeTo(10, 4), - ]); - }); }); describe('En face layout source slice linking', () => { diff --git a/src/enface/__tests__/viewer.spec.ts b/src/enface/__tests__/viewer.spec.ts index f721d8674..4952827ef 100644 --- a/src/enface/__tests__/viewer.spec.ts +++ b/src/enface/__tests__/viewer.spec.ts @@ -81,19 +81,6 @@ it('enables pane interactions only when their projection source supports them', tools.setCurrentTool(Tools.Crosshairs); await nextTick(); expect(wrapper.findComponent(CrosshairsWidget).exists()).toBe(true); - const image = useImageCacheStore().getVtkImageData('oct')!; - const extent = [...image.getExtent()]; - image.setExtent( - 10, - extent[1] + 10, - extent[2], - extent[3], - extent[4], - extent[5] - ); - await nextTick(); - expect(tools.currentTool).toBe(Tools.Crosshairs); - expect(wrapper.findComponent(CrosshairsWidget).exists()).toBe(false); tools.setCurrentTool(Tools.WindowLevel); diff --git a/src/enface/sliceGeometry.ts b/src/enface/sliceGeometry.ts index 118666fd0..58d9a8b00 100644 --- a/src/enface/sliceGeometry.ts +++ b/src/enface/sliceGeometry.ts @@ -10,24 +10,14 @@ export function enFaceSliceLine( peer: SlicePlane, metadata: ImageMetadata ) { - const { sourceExtent, pixelToWorld, planeNormal, planeOrigin } = geometry; - const minimum: Vector3 = [sourceExtent[0], sourceExtent[2], sourceExtent[4]]; - const localToWorld = mat4.translate( - new Array(16) as mat4, - metadata.indexToWorld, - minimum - ); + const { pixelToWorld, planeNormal, planeOrigin } = geometry; const line = computeReferenceLine( { normal: planeNormal, origin: planeOrigin, }, peer, - { - ...metadata, - indexToWorld: localToWorld, - worldToIndex: mat4.invert(new Array(16) as mat4, localToWorld), - } + metadata ); if (!line) return null; const worldToPixel = mat4.invert(new Array(16) as mat4, pixelToWorld); diff --git a/src/store/tools/__tests__/enFaceCapabilities.spec.ts b/src/store/tools/__tests__/enFaceCapabilities.spec.ts index 32f4c4b14..26ac70fce 100644 --- a/src/store/tools/__tests__/enFaceCapabilities.spec.ts +++ b/src/store/tools/__tests__/enFaceCapabilities.spec.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { enableAutoUnmount, mount } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import ControlsStripTools from '@/src/components/ControlsStripTools.vue'; -import ReasonedAction from '@/src/components/ReasonedAction.vue'; import { createPinia, setActivePinia } from 'pinia'; import { nextTick } from 'vue'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -159,41 +158,6 @@ describe('temporary crosshairs in the toolbar', () => { }); describe('En face tool capabilities', () => { - it('keeps offset-grid projection available while disabling Crosshairs and its shortcut with a shared reason', async () => { - const dataID = addRegularVolume(); - useImageCacheStore() - .getVtkImageData(dataID)! - .setExtent(10, 11, 0, 1, 30, 31); - activateEnFace(dataID); - vi.stubGlobal('visualViewport', null); - const toolbar = mount(ControlsStripTools, { - attachTo: document.body, - global: { plugins: [createVuetify()] }, - }); - const selector = 'button[data-testid^="control-button-Crosshairs "]'; - const reason = getToolUnavailableReason( - Tools.Crosshairs, - getEffectiveView(useViewStore().activeView) - ); - expect(getEnFaceUnavailableReason(dataID)).toBeNull(); - expect(toolbar.get(selector).attributes('disabled')).toBeDefined(); - const action = toolbar - .findAllComponents(ReasonedAction) - .find((candidate) => candidate.find(selector).exists())!; - expect(action.props('reason')).toBe(reason); - expect(reason).toMatch(/nonzero starting voxel indices/); - const tools = useToolStore(); - tools.setCurrentTool(Tools.Crosshairs); - await nextTick(); - const selected = () => - toolbar.get('button[data-testid^="control-button-Window & Level "]'); - expect(selected().classes()).toContain('tool-btn-selected'); - tools.setCurrentTool(Tools.WindowLevel); - await crosshairsChord('keydown'); - expect(selected().classes()).toContain('tool-btn-selected'); - await crosshairsChord('keyup'); - expect(selected().classes()).toContain('tool-btn-selected'); - }); it.each(Object.values(Tools))( 'supports viewing tools when evaluating %s', (tool) => { diff --git a/src/store/tools/crosshairs.ts b/src/store/tools/crosshairs.ts index 0531322fe..f3b3d6c75 100644 --- a/src/store/tools/crosshairs.ts +++ b/src/store/tools/crosshairs.ts @@ -4,10 +4,7 @@ import { vec3 } from 'gl-matrix'; import { defineStore } from 'pinia'; import { useViewStore } from '@/src/store/views'; import useViewSliceStore from '@/src/store/view-configs/slicing'; -import { - getImageData, - getImageMetadata, -} from '@/src/composables/useCurrentImage'; +import { getImageMetadata } from '@/src/composables/useCurrentImage'; import { computeEffectiveView, volume2DViewsOfImage, @@ -17,17 +14,6 @@ import { clampValue } from '@/src/utils'; import type { ImageMetadata } from '@/src/types/image'; import { getEnFaceUnavailableReason } from '@/src/enface/availability'; import { useEnFaceViewStore } from '@/src/enface/store'; -import type { Maybe } from '@/src/types'; - -export function getEnFaceCrosshairsUnavailableReason(imageID: Maybe) { - const sourceReason = getEnFaceUnavailableReason(imageID); - if (sourceReason) return sourceReason; - const extent = getImageData(imageID)?.getExtent(); - // Crosshairs clamp picked indices against zero-based image dimensions. - return extent && [0, 2, 4].some((index) => extent[index] !== 0) - ? 'Crosshairs navigation is unavailable for source grids with nonzero starting voxel indices.' - : null; -} /** The world point in `metadata`'s index space, pulled inside the image box. */ function clampToImage(worldPosition: Vector3, metadata: ImageMetadata) { @@ -76,10 +62,7 @@ export const useCrosshairsToolStore = defineStore('crosshairs', () => { const view = viewStore.getView(viewId); const host = view && computeEffectiveView(view, view.dataID); if (host?.kind !== 'volume2D' && host?.kind !== 'enface') return; - if ( - host.kind === 'enface' && - getEnFaceCrosshairsUnavailableReason(host.renderDataID) - ) + if (host.kind === 'enface' && getEnFaceUnavailableReason(host.renderDataID)) return; const imageID = host.renderDataID; diff --git a/src/store/tools/index.ts b/src/store/tools/index.ts index a6c2a5372..1c7a08256 100644 --- a/src/store/tools/index.ts +++ b/src/store/tools/index.ts @@ -4,10 +4,7 @@ import type { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; import { defineStore } from 'pinia'; import { computed, ref, watch } from 'vue'; import { useCropStore } from './crop'; -import { - getEnFaceCrosshairsUnavailableReason, - useCrosshairsToolStore, -} from './crosshairs'; +import { useCrosshairsToolStore } from './crosshairs'; import { usePaintToolStore } from './paint'; import { useRulerStore } from './rulers'; import { useRectangleStore } from './rectangles'; @@ -55,9 +52,7 @@ export function getToolUnavailableReason( } if (effective?.viewInfo.type !== 'EnFace' || tool === Tools.Select) return null; - return tool === Tools.Crosshairs - ? getEnFaceCrosshairsUnavailableReason(effective.renderDataID) - : getEnFaceUnavailableReason(effective.renderDataID); + return getEnFaceUnavailableReason(effective.renderDataID); } // These tools draw into the selected segment, so picking one up seats a // segment: the palette shows the color the next stroke will be before it is diff --git a/src/store/view-configs/slicing.ts b/src/store/view-configs/slicing.ts index 6ededc277..e87242bf1 100644 --- a/src/store/view-configs/slicing.ts +++ b/src/store/view-configs/slicing.ts @@ -37,16 +37,15 @@ export const useViewSliceStore = defineStore('viewSlice', () => { if (view?.type !== '2D') return defaultSliceConfig(); const { orientation } = view.options; - const { metadata, imageData } = useImage(imageID); + const { metadata } = useImage(imageID); const { lpsOrientation, dimensions } = metadata.value; const ijkIndex = lpsOrientation[orientation]; - const min = imageData.value?.getExtent()[2 * ijkIndex] ?? 0; - const max = min + dimensions[ijkIndex] - 1; + const dimMax = dimensions[ijkIndex]; return { - min, - slice: Math.ceil((min + max) / 2), - max, + min: 0, + slice: Math.ceil((dimMax - 1) / 2), + max: dimMax - 1, syncState: false, }; }; diff --git a/tests/fixtures/oct/volumes.ts b/tests/fixtures/oct/volumes.ts index f2b48de77..4b5a92e4f 100644 --- a/tests/fixtures/oct/volumes.ts +++ b/tests/fixtures/oct/volumes.ts @@ -140,10 +140,10 @@ export function createNiftiVolume({ return Buffer.concat([header, scalarBytes(scalars)]); } -/** ASCII VTK image data, for grids NRRD and NIfTI cannot express such as offset extents. */ +/** ASCII VTK image data, for inputs NRRD and NIfTI cannot express such as signed spacing. */ export function createVtiVolume({ values, - extent, + dimensions, spacing = [1, 1, 1], origin = [0, 0, 0], direction, @@ -152,7 +152,7 @@ export function createVtiVolume({ components, }: { values: ArrayLike; - extent: readonly number[]; + dimensions: readonly number[]; spacing?: readonly number[]; origin?: readonly number[]; direction?: readonly number[]; @@ -160,7 +160,7 @@ export function createVtiVolume({ name?: string; components?: number; }) { - const range = extent.join(' '); + const range = dimensions.map((size) => `0 ${size - 1}`).join(' '); return ( '' + ` 17 * x + 11 * y + 7 * z; - -function vti(offsetExtent: boolean) { - const minimum = offsetExtent ? MINIMUM : [0, 0, 0]; - const origin = offsetExtent ? [0, 0, 0] : MINIMUM; - const extent = minimum.flatMap((first, axis) => [ - first, - first + SIZE[axis] - 1, - ]); - const values = Array.from({ length: SIZE[0] * SIZE[1] * SIZE[2] }, (_, i) => - valueAt( - i % SIZE[0], - Math.floor(i / SIZE[0]) % SIZE[1], - Math.floor(i / (SIZE[0] * SIZE[1])) - ) - ); - return createVtiVolume({ extent, origin, type: 'Float32', values }); -} - function signedFitVolume(negativeAxis?: 0 | 2) { const spacing = [negativeAxis === 0 ? -4 : 4, 1, negativeAxis === 2 ? -1 : 1]; const direction = [ @@ -64,7 +40,7 @@ function signedFitVolume(negativeAxis?: 0 | 2) { return x === 2 || x === 17 ? 0 : 70; }); return createVtiVolume({ - extent: [0, 19, 0, 9, 0, 9], + dimensions: [20, 10, 10], spacing, direction, type: 'UInt8', @@ -136,8 +112,6 @@ describe('Slab geometry and automatic camera fitting', () => { before(async () => { const directory = makeTempDir(STEM); - fs.writeFileSync(path.join(directory, 'zero.vti'), vti(false)); - fs.writeFileSync(path.join(directory, 'offset.vti'), vti(true)); fs.writeFileSync(path.join(directory, 'reflected.vti'), signedFitVolume()); fs.writeFileSync(path.join(directory, 'signed-x.vti'), signedFitVolume(0)); fs.writeFileSync(path.join(directory, 'signed-z.vti'), signedFitVolume(2)); @@ -157,56 +131,6 @@ describe('Slab geometry and automatic camera fitting', () => { ); }); - for (const axis of [0, 1, 2]) { - it(`renders physically equivalent offset extents on axis ${axis}`, async () => { - let expectedImage = ''; - let expectedSource = ''; - for (const name of ['zero.vti', 'offset.vti']) { - await openScene(name); - await chooseEnFaceProjection('enface-projection-axis', AXES[axis]); - await setEnFaceWindow(200, 100); - await setEnFaceSlab(1, SIZE[axis] - 2); - await waitForEnFaceReady(); - const point = [1, 1]; - const index = [1, 1, 1]; - index[axis] = (SIZE[axis] - 1) / 2; - const expectedGray = Math.round( - (255 * valueAt(index[0], index[1], index[2])) / 200 - ); - await browser.waitUntil(async () => { - const pixel = await enFacePixel(point[0], point[1]); - return ( - pixel[3] === 255 && - pixel - .slice(0, 3) - .every((channel) => Math.abs(channel - expectedGray) <= 1) - ); - }); - if (expectedImage) await expectPixels(expectedImage); - else expectedImage = (await enFaceAppearance()).image; - await expect( - $('[data-testid~="vtk-two-view"] .slice-label') - ).toHaveText('Slice: 3/5'); - const source = await $(SOURCE).getElement(); - await browser.waitUntil( - async () => { - const appearance = await canvasAppearance(source); - return ( - appearance.visiblePixels > 0 && - (!expectedSource || appearance.image === expectedSource) - ); - }, - { - timeoutMsg: - 'Expected equivalent visible source slices for offset extents', - } - ); - expectedSource = (await canvasAppearance(source)).image; - } - expect(await volViewPage.getNotificationsCount()).toBe(0); - }); - } - it('fits signed-spacing equivalents with both edge fiducials after load and reset', async () => { let expectedProjection = ''; let expectedSource = ''; diff --git a/tests/specs/slab-volume-inputs.e2e.ts b/tests/specs/slab-volume-inputs.e2e.ts index 4ec791286..716170a64 100644 --- a/tests/specs/slab-volume-inputs.e2e.ts +++ b/tests/specs/slab-volume-inputs.e2e.ts @@ -45,7 +45,7 @@ function stageColorVolume() { fs.writeFileSync( path.join(makeTempDir(STEM), 'color.vti'), createVtiVolume({ - extent: [0, 3, 0, 3, 0, 3], + dimensions: [4, 4, 4], type: 'UInt8', name: 'color', components: 3, From 89902e8827a6ff0e5a54c885aac4144c086ce6bd Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 1 Nov 2026 18:57:46 -0500 Subject: [PATCH 5/5] refactor(views): derive the active named layout from the panes --- src/enface/__tests__/layout.spec.ts | 29 ++++++++++----- .../__tests__/serializeResilience.spec.ts | 2 - src/io/state-file/schema.ts | 1 - src/io/state-file/serialize.ts | 2 - src/store/views.ts | 37 +++++++++++++------ 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/enface/__tests__/layout.spec.ts b/src/enface/__tests__/layout.spec.ts index 82a66b03a..3be19ce4f 100644 --- a/src/enface/__tests__/layout.spec.ts +++ b/src/enface/__tests__/layout.spec.ts @@ -37,15 +37,26 @@ function configureComparison() { } describe('En face view configuration', () => { - it.each(['custom', 'named', 'legacy named'])( - 'restores the %s layout selection without inferring its identity from panes', - async (selection) => { + it.each([ + ['named', true], + ['regridded', false], + ['retyped', false], + ] as const)( + 'highlights a %s layout after restore only when its panes match', + async (selection, highlighted) => { await loadVolume('oct'); const views = useViewStore(); views.setDataForAllViews('oct'); configureComparison(); views.switchToNamedLayout('Comparison'); - if (selection === 'custom') views.setLayoutFromGrid([1, 2]); + if (selection === 'regridded') views.setLayoutFromGrid([2, 1]); + if (selection === 'retyped') + views.replaceView(views.layoutViews[1].id, { + name: 'Oblique', + type: 'Oblique', + dataID: 'oct', + options: {}, + }); const hidden = views .getAllViews() .filter((view) => !views.layoutViews.includes(view)); @@ -63,7 +74,6 @@ describe('En face view configuration', () => { }); const zip = await JSZip.loadAsync(saved); const raw = JSON.parse(await zip.file(MANIFEST)!.async('string')); - if (selection === 'legacy named') delete raw.currentLayoutName; const manifest = ManifestSchema.parse(raw); await cleanupCachedImages([]); setActivePinia(createPinia()); @@ -77,7 +87,10 @@ describe('En face view configuration', () => { restored.layoutViews.map(({ type, dataID }) => ({ type, dataID })) ).toEqual([ { type: '2D', dataID: 'restored-oct' }, - { type: 'EnFace', dataID: 'restored-oct' }, + { + type: selection === 'retyped' ? 'Oblique' : 'EnFace', + dataID: 'restored-oct', + }, ]); hidden.forEach(({ id, type }) => expect(restored.getView(id)?.type).toBe(type) @@ -87,9 +100,7 @@ describe('En face view configuration', () => { }); const named = wrapper.get('.v-list-item'); expect(named.text()).toBe('Comparison'); - expect(named.classes().includes('v-list-item--active')).toBe( - selection === 'named' - ); + expect(named.classes().includes('v-list-item--active')).toBe(highlighted); expect(Object.keys(restored.namedLayouts)).toEqual(['Comparison']); } ); diff --git a/src/io/state-file/__tests__/serializeResilience.spec.ts b/src/io/state-file/__tests__/serializeResilience.spec.ts index f27431aaf..f9d1b6724 100644 --- a/src/io/state-file/__tests__/serializeResilience.spec.ts +++ b/src/io/state-file/__tests__/serializeResilience.spec.ts @@ -168,7 +168,6 @@ describe('state-file serialization resilience', () => { ...manifestWithSelection('dataset-1'), activeView: 'view-1', isActiveViewMaximized: true, - currentLayoutName: 'Four Up', layout: { direction: 'column', items: [{ type: 'slot', slotIndex: 0 }], @@ -191,7 +190,6 @@ describe('state-file serialization resilience', () => { expect(normalized.manifest).not.toHaveProperty('activeView'); expect(normalized.manifest).not.toHaveProperty('isActiveViewMaximized'); expect(normalized.manifest).not.toHaveProperty('layout'); - expect(normalized.manifest).not.toHaveProperty('currentLayoutName'); expect(normalized.manifest).not.toHaveProperty('layoutSlots'); expect(normalized.omitted).toContain('view/layout: invalid viewByID state'); }); diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index 839d934f5..93801871d 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -539,7 +539,6 @@ export const ManifestSchema = z.object({ viewByID: z.record(z.string(), View).optional(), primarySelection: z.string().optional(), layout: Layout.optional(), - currentLayoutName: z.string().nullable().optional(), layoutSlots: z.array(z.string()).optional(), parentToLayers: ParentToLayers.optional(), layerDisplay: z.record(z.string(), LayerDisplayConfig).optional(), diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts index 5ba89450c..9a835f2cb 100644 --- a/src/io/state-file/serialize.ts +++ b/src/io/state-file/serialize.ts @@ -232,7 +232,6 @@ export function normalizeManifest(manifest: Manifest, zip: JSZip) { 'isActiveViewMaximized', 'viewByID', 'layout', - 'currentLayoutName', 'layoutSlots', ]); const invalidViewByID = @@ -257,7 +256,6 @@ export function normalizeManifest(manifest: Manifest, zip: JSZip) { 'viewByID', 'primarySelection', 'layout', - 'currentLayoutName', 'layoutSlots', ] as const; const optionalEntries = optionalRoots.flatMap((key) => { diff --git a/src/store/views.ts b/src/store/views.ts index 9760b60c6..481b782b6 100644 --- a/src/store/views.ts +++ b/src/store/views.ts @@ -97,6 +97,19 @@ function generateLayoutFromGrid(size: [number, number]): Layout { }; } +function sameLayout(a: Layout, b: Layout): boolean { + return ( + a.direction === b.direction && + a.items.length === b.items.length && + a.items.every((item, index) => { + const other = b.items[index]; + if (item.type === 'slot') + return other.type === 'slot' && item.slotIndex === other.slotIndex; + return other.type === 'layout' && sameLayout(item, other); + }) + ); +} + function needsViewReplacement(existingView: ViewInfo, nextView: ViewInfoInit) { if (existingView.type !== nextView.type) return true; @@ -121,7 +134,6 @@ export const useViewStore = defineStore('view', () => { const defaultNamedLayoutEntries = Object.entries(parsedDefaultLayouts); const firstLayout = defaultNamedLayoutEntries[0][1]; - const firstLayoutName = defaultNamedLayoutEntries[0][0]; const layout = ref(firstLayout.layout); // which assigns view IDs to layout slots @@ -133,7 +145,18 @@ export const useViewStore = defineStore('view', () => { ref>( parsedDefaultLayouts ); - const currentLayoutName = ref>(firstLayoutName); + // A named layout is current when switching to it would only rename views. + const currentLayoutName = computed( + () => + Object.entries(namedLayouts.value).find( + ([, named]) => + sameLayout(named.layout, layout.value) && + named.views.every((viewInit, index) => { + const view = viewByID[layoutSlots.value[index]]; + return view && !needsViewReplacement(view, viewInit); + }) + )?.[0] + ); const isActiveViewMaximized = ref(false); const maximizedView = computed(() => { @@ -242,14 +265,9 @@ export const useViewStore = defineStore('view', () => { function applyLayoutChange( newLayout: Layout, options?: { - layoutName?: Maybe; updateSlots?: (viewCount: number) => void; } ) { - if (options?.layoutName !== undefined) { - currentLayoutName.value = options.layoutName; - } - isActiveViewMaximized.value = false; if (options?.updateSlots) { @@ -273,7 +291,6 @@ export const useViewStore = defineStore('view', () => { } function setLayoutFromGrid(gridSize: [number, number]) { - currentLayoutName.value = null; setLayout(generateLayoutFromGrid(gridSize)); } @@ -287,7 +304,6 @@ export const useViewStore = defineStore('view', () => { throw new Error(`Named layout "${name}" not found`); } applyLayoutChange(namedLayout.layout, { - layoutName: name, updateSlots: () => { namedLayout.views.forEach((viewInit, index) => { if (index < layoutSlots.value.length) { @@ -359,7 +375,6 @@ export const useViewStore = defineStore('view', () => { function serialize(stateFile: StateFile) { const { manifest } = stateFile; manifest.layout = layout.value; - manifest.currentLayoutName = currentLayoutName.value; manifest.activeView = activeView.value; manifest.isActiveViewMaximized = isActiveViewMaximized.value; manifest.layoutSlots = layoutSlots.value; @@ -368,8 +383,6 @@ export const useViewStore = defineStore('view', () => { function deserializeLayout(manifest: Manifest) { if (manifest.layout) { - // Older scenes have unknown selection identity, even for matching panes. - currentLayoutName.value = manifest.currentLayoutName; setLayout(manifest.layout); } setActiveView(manifest.activeView);