From 1a344b5c93106ad77a7ccbab40581b27dc43edc8 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Wed, 28 Oct 2026 20:36:17 -0400 Subject: [PATCH 1/3] feat: highlight occupied depth in en face views --- eslint.config.js | 9 +- scripts/samples/jhu-oct-provenance.json | 2 +- src/core/slab/geometry.ts | 8 + src/core/streaming/dicom/frameGeometry.ts | 72 +++ src/core/streaming/dicom/octMetadata.ts | 59 +-- src/core/streaming/dicom/seriesGeometry.ts | 150 +++++++ src/enface/EnFaceControlPanel.vue | 7 + src/enface/EnFaceControls.vue | 9 +- src/enface/EnFaceMeasurementStatus.vue | 30 ++ src/enface/EnFaceThicknessControls.vue | 187 ++++++++ src/enface/EnFaceViewer.vue | 19 +- .../VtkEnFaceLabelmapRepresentation.vue | 41 ++ src/enface/__tests__/calibration.spec.ts | 44 ++ src/enface/__tests__/controls.spec.ts | 392 ++++++++++++++++- src/enface/__tests__/inputCalibration.spec.ts | 278 ++++++++++++ src/enface/__tests__/labelmapGeometry.spec.ts | 99 +++++ src/enface/__tests__/layout.spec.ts | 2 + src/enface/__tests__/manualVolume.spec.ts | 35 +- .../occupiedDepthPresentation.spec.ts | 54 +++ .../__tests__/occupiedDepthSupport.spec.ts | 108 +++++ src/enface/__tests__/restoreSelection.spec.ts | 237 ++++++++++ src/enface/__tests__/seriesGeometry.spec.ts | 125 +++++- src/enface/__tests__/store.spec.ts | 95 +++- .../__tests__/thinRegionAppearance.spec.ts | 139 ++++++ src/enface/occupiedDepthSupport.ts | 73 ++++ src/enface/projectionMasks.ts | 18 + src/enface/store.ts | 56 ++- src/enface/thickness.ts | 27 ++ src/enface/useEnFaceLabelmaps.ts | 85 ++++ src/enface/useEnFaceOccupiedDepth.ts | 77 ++++ src/enface/useOccupiedDepthInput.ts | 290 ++++++++++++ src/enface/useThinRegionAppearance.ts | 41 ++ src/io/import/processors/restoreStateFile.ts | 6 +- src/io/state-file/schema.ts | 20 +- .../__tests__/occupiedDepth.spec.ts | 139 ++++++ .../__tests__/useOccupiedDepth.spec.ts | 253 +++++++++++ .../measurements/occupiedDepth.ts | 166 +++++++ .../measurements/occupiedDepth.worker.ts | 9 + .../measurements/occupiedDepthHandler.ts | 41 ++ .../measurements/useOccupiedDepth.ts | 170 ++++++++ .../VtkSegmentationSliceRepresentation.vue | 101 +---- .../rendering/useLabelmapRepresentation.ts | 90 ++++ src/store/view-configs.ts | 10 +- tests/fixtures/oct/volumes.ts | 19 + tests/pageobjects/volview.page.ts | 4 +- tests/specs/menuTestUtils.ts | 12 + tests/specs/oct-en-face.e2e.ts | 69 ++- tests/specs/oct-import.e2e.ts | 23 +- tests/specs/oct-native-dicom.e2e.ts | 61 ++- tests/specs/oct-navigation.e2e.ts | 16 +- tests/specs/oct-occupied-depth.e2e.ts | 402 +++++++++++++++++ tests/specs/octInteractionTestUtils.ts | 83 +++- tests/specs/octTestUtils.ts | 41 +- tests/specs/projection-camera.e2e.ts | 89 +++- tests/specs/segmentationTestUtils.ts | 20 +- tests/specs/slab-occupied-depth.e2e.ts | 412 ++++++++++++++++++ 56 files changed, 4881 insertions(+), 243 deletions(-) create mode 100644 src/core/streaming/dicom/frameGeometry.ts create mode 100644 src/core/streaming/dicom/seriesGeometry.ts create mode 100644 src/enface/EnFaceMeasurementStatus.vue create mode 100644 src/enface/EnFaceThicknessControls.vue create mode 100644 src/enface/VtkEnFaceLabelmapRepresentation.vue create mode 100644 src/enface/__tests__/calibration.spec.ts create mode 100644 src/enface/__tests__/inputCalibration.spec.ts create mode 100644 src/enface/__tests__/labelmapGeometry.spec.ts create mode 100644 src/enface/__tests__/occupiedDepthPresentation.spec.ts create mode 100644 src/enface/__tests__/occupiedDepthSupport.spec.ts create mode 100644 src/enface/__tests__/restoreSelection.spec.ts create mode 100644 src/enface/__tests__/thinRegionAppearance.spec.ts create mode 100644 src/enface/occupiedDepthSupport.ts create mode 100644 src/enface/projectionMasks.ts create mode 100644 src/enface/thickness.ts create mode 100644 src/enface/useEnFaceLabelmaps.ts create mode 100644 src/enface/useEnFaceOccupiedDepth.ts create mode 100644 src/enface/useOccupiedDepthInput.ts create mode 100644 src/enface/useThinRegionAppearance.ts create mode 100644 src/segmentation/measurements/__tests__/occupiedDepth.spec.ts create mode 100644 src/segmentation/measurements/__tests__/useOccupiedDepth.spec.ts create mode 100644 src/segmentation/measurements/occupiedDepth.ts create mode 100644 src/segmentation/measurements/occupiedDepth.worker.ts create mode 100644 src/segmentation/measurements/occupiedDepthHandler.ts create mode 100644 src/segmentation/measurements/useOccupiedDepth.ts create mode 100644 src/segmentation/rendering/useLabelmapRepresentation.ts create mode 100644 tests/specs/menuTestUtils.ts create mode 100644 tests/specs/oct-occupied-depth.e2e.ts create mode 100644 tests/specs/slab-occupied-depth.e2e.ts diff --git a/eslint.config.js b/eslint.config.js index e64605c27..5dcc45463 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -313,12 +313,19 @@ export default tseslint.config( { dir: 'enface', publicSurface: false, - pure: ['src/enface/sliceGeometry.ts'], + pure: [ + 'src/enface/thickness.ts', + 'src/enface/sliceGeometry.ts', + 'src/enface/projectionMasks.ts', + ], }, { dir: 'segmentation', pure: [ 'src/segmentation/geometry.ts', + 'src/segmentation/measurements/occupiedDepth.ts', + 'src/segmentation/measurements/occupiedDepth.worker.ts', + 'src/segmentation/measurements/occupiedDepthHandler.ts', 'src/segmentation/color.ts', 'src/segmentation/model.ts', 'src/segmentation/segment.ts', diff --git a/scripts/samples/jhu-oct-provenance.json b/scripts/samples/jhu-oct-provenance.json index 873b51183..df63077ae 100644 --- a/scripts/samples/jhu-oct-provenance.json +++ b/scripts/samples/jhu-oct-provenance.json @@ -93,7 +93,7 @@ "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.", + "axial_sampling": "Native depth samples and ScaleZ are preserved; thickness is occupied depth voxels multiplied by 3.867251332849264 microns.", "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.", diff --git a/src/core/slab/geometry.ts b/src/core/slab/geometry.ts index 5aa8830cd..ce22f1fd3 100644 --- a/src/core/slab/geometry.ts +++ b/src/core/slab/geometry.ts @@ -21,6 +21,11 @@ export function deriveSlabGeometry( spec: SlabSpecification ) { const axes = [0, 1, 2].filter((axis) => axis !== spec.axis); + const dimensions = image.getDimensions(); + const sourceSpacing = image.getSpacing(); + const width = dimensions[axes[0]]; + const height = dimensions[axes[1]]; + const spacing = axes.map((axis) => Math.abs(sourceSpacing[axis])); const planeIndex: Vector3 = [0, 0, 0]; planeIndex[spec.axis] = (spec.first + spec.last) / 2; const planeOrigin = image.indexToWorld(planeIndex) as Vector3; @@ -48,6 +53,9 @@ export function deriveSlabGeometry( ...metadataFromVtkImageData(image), }; return { + width, + height, + spacing, metadata, pixelToWorld, planeOrigin, diff --git a/src/core/streaming/dicom/frameGeometry.ts b/src/core/streaming/dicom/frameGeometry.ts new file mode 100644 index 000000000..99c1afda5 --- /dev/null +++ b/src/core/streaming/dicom/frameGeometry.ts @@ -0,0 +1,72 @@ +import { vec3 } from 'gl-matrix'; + +export const numbersMatch = (a: number, b: number, tolerance = 1e-5) => + Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tolerance; +export const coordinatesMatch = ( + a: ArrayLike, + b: ArrayLike, + tolerance = 1e-5 +) => + a.length === b.length && + Array.from(a).every((n, i) => numbersMatch(n, b[i], tolerance)); +export function imageDirection(basis: number[] | null | undefined) { + if (!basis || basis.length !== 6 || !basis.every(Number.isFinite)) + return null; + const row = basis.slice(0, 3) as vec3; + const column = basis.slice(3) as vec3; + if ( + !numbersMatch(vec3.length(row), 1, 1e-4) || + !numbersMatch(vec3.length(column), 1, 1e-4) || + // Rounding two unit vectors to four decimals can shift their dot by 1.8e-4. + !numbersMatch(vec3.dot(row, column), 0, 2e-4) + ) + return null; + return [...basis, ...vec3.cross([0, 0, 0], row, column)]; +} + +/** Validate equally spaced slices along the positive image normal. */ +export function frameGridGeometry(positions: number[][], direction?: number[]) { + if (positions.some((p) => p.length !== 3 || !p.every(Number.isFinite))) + return { + reason: + 'DICOM slice positions are invalid; spatial reconstruction is required.', + }; + if (positions.length < 2) return { reason: null }; + const step = positions + .at(-1)! + .map((n, axis) => (n - positions[0][axis]) / (positions.length - 1)); + const distance = Math.hypot(...step); + // Patient positions may be printed to four decimal places in millimetres. + const tolerance = Math.max(1e-4, distance * 1e-3); + if (distance <= 1e-5) + return { + reason: 'Repeated DICOM slice locations need spatial reconstruction.', + }; + if ( + direction && + !coordinatesMatch( + step, + direction.slice(6).map((n) => n * distance), + tolerance + ) + ) + return { + reason: + 'DICOM slice positions do not follow the image grid; spatial reconstruction is required.', + }; + if ( + positions.some( + (position, i) => + !coordinatesMatch( + position, + positions[0].map((n, axis) => n + i * step[axis]), + tolerance + ) + ) + ) + return { + reason: + 'DICOM slices have nonuniform positions; spatial reconstruction is required.', + }; + return { reason: null, distance }; +} diff --git a/src/core/streaming/dicom/octMetadata.ts b/src/core/streaming/dicom/octMetadata.ts index f1153b755..141d0e744 100644 --- a/src/core/streaming/dicom/octMetadata.ts +++ b/src/core/streaming/dicom/octMetadata.ts @@ -1,8 +1,11 @@ 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 { + frameGridGeometry, + imageDirection, +} from '@/src/core/streaming/dicom/frameGeometry'; import { isOCTMetadata, OCT_GEOMETRY_ERROR, @@ -10,8 +13,6 @@ import { } from '@/src/core/streaming/dicom/octMetadataTags'; import type { Maybe } from '@/src/types'; -const close = (a: number, b: number, tolerance: number) => - Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tolerance; const finiteVector = ( value: number[] | null | undefined, length: number @@ -19,56 +20,6 @@ const finiteVector = ( value?.length === length && value.every(Number.isFinite); const positiveNumber = (value: number) => Number.isFinite(value) && value > 0; -function imageDirection(basis: number[] | null | undefined) { - if (!finiteVector(basis, 6)) return null; - const row = basis.slice(0, 3) as vec3; - const column = basis.slice(3) as vec3; - if ( - !close(vec3.length(row), 1, 1e-4) || - !close(vec3.length(column), 1, 1e-4) || - // Rounding two unit vectors to four decimals can shift their dot by 1.8e-4. - !close(vec3.dot(row, column), 0, 2e-4) - ) - return null; - return [...basis, ...vec3.cross([0, 0, 0], row, column)]; -} - -/** Validate equally spaced slices along the positive image normal. */ -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; - const step = positions - .at(-1)! - .map((n, axis) => (n - positions[0][axis]) / (positions.length - 1)); - const distance = Math.hypot(...step); - // Patient positions may be printed to four decimal places in millimetres. - const tolerance = Math.max(1e-4, distance * 1e-3); - if (distance <= 1e-5) - return 'Repeated DICOM slice locations need spatial reconstruction.'; - if ( - direction && - !areEquals( - step, - direction.slice(6).map((n) => n * distance), - tolerance - ) - ) - return 'DICOM slice positions do not follow the image grid; spatial reconstruction is required.'; - if ( - positions.some( - (position, i) => - !areEquals( - position, - positions[0].map((n, axis) => n + i * step[axis]), - tolerance - ) - ) - ) - return 'DICOM slices have nonuniform positions; spatial reconstruction is required.'; - return null; -} - const SHARED = 'x52009229'; const PER_FRAME = 'x52009230'; const MEASURES = 'x00289110'; @@ -161,7 +112,7 @@ function positionError(geometry: ReturnType) { !positiveNumber(Number(geometry.sliceSpacing)) ) return 'OCT slice spacing is unavailable; spatial reconstruction is required.'; - return frameGridError(positions, direction); + return frameGridGeometry(positions, direction ?? undefined).reason; } function geometryError(geometry: ReturnType) { diff --git a/src/core/streaming/dicom/seriesGeometry.ts b/src/core/streaming/dicom/seriesGeometry.ts new file mode 100644 index 000000000..11df04076 --- /dev/null +++ b/src/core/streaming/dicom/seriesGeometry.ts @@ -0,0 +1,150 @@ +import { Tags } from '@/src/core/dicomTags'; + +import { + coordinatesMatch, + frameGridGeometry, + imageDirection, + numbersMatch, +} from '@/src/core/streaming/dicom/frameGeometry'; +import { OCT_GEOMETRY_ERROR } from '@/src/core/streaming/dicom/octMetadataTags'; + +type Grid = { + spacing: ArrayLike; + direction: ArrayLike; + origin: ArrayLike; +}; + +function vector(tags: Map, tag: string, length: number) { + const result = tags.get(tag)?.split('\\').map(Number); + return result?.length === length && result.every(Number.isFinite) + ? result + : null; +} + +function declaredSeriesReason(tags: Map[]) { + const reason = tags + .map((entry) => entry.get(OCT_GEOMETRY_ERROR)) + .find(Boolean); + if (reason) return reason; + const sop = tags[0].get(Tags.SOPClassUID)?.trim(); + if (tags.some((entry) => entry.get(Tags.SOPClassUID)?.trim() !== sop)) + return 'The DICOM files have incompatible image types; spatial reconstruction is required.'; + const spacing = vector(tags[0], Tags.PixelSpacing, 2); + if ( + tags.some((entry) => { + const other = vector(entry, Tags.PixelSpacing, 2); + return spacing && other + ? !coordinatesMatch(spacing, other) + : spacing !== other; + }) + ) + return 'The DICOM files have inconsistent pixel spacing; spatial reconstruction is required.'; + return null; +} + +function seriesPositions(tags: Map[]) { + const positions = tags.map((entry) => + vector(entry, Tags.ImagePositionPatient, 3) + ); + return positions.every((position) => position !== null) ? positions : null; +} + +function loadedGridReason( + grid: Grid | undefined, + direction: number[], + origin: number[], + distance: number +) { + if ( + grid && + (!coordinatesMatch(grid.direction, direction) || + !coordinatesMatch(grid.origin, origin) || + !numbersMatch(grid.spacing[2], distance)) + ) + return 'The loaded DICOM grid does not match its file geometry; spatial reconstruction is required.'; + return null; +} + +function seriesGeometry(tags: Map[], grid?: Grid) { + const declaredReason = declaredSeriesReason(tags); + if (declaredReason) return { reason: declaredReason }; + const direction = imageDirection( + vector(tags[0], Tags.ImageOrientationPatient, 6) + ); + if ( + !direction || + tags.some((entry) => { + const other = imageDirection( + vector(entry, Tags.ImageOrientationPatient, 6) + ); + return !other || !coordinatesMatch(direction, other); + }) + ) + return { + reason: + 'The DICOM files have missing or inconsistent image orientation; spatial reconstruction is required.', + }; + const positions = seriesPositions(tags); + if (!positions) + return { + reason: + 'The DICOM files have incomplete or invalid slice positions; spatial reconstruction is required.', + }; + const geometry = frameGridGeometry(positions, direction); + if (geometry.reason) return geometry; + const distance = geometry.distance!; + const reason = loadedGridReason(grid, direction, positions[0], distance); + return { reason, distance }; +} + +function declaredPixelSpacing(tags: Map, component: number) { + const spacing = tags.get(Tags.PixelSpacing)?.split('\\').map(Number); + const value = spacing?.length === 2 ? spacing[component] : NaN; + return Number.isFinite(value) && value! > 0 ? value : NaN; +} + +function calibratedAxes( + tags: Map[], + grid: Grid | undefined, + distance: number | undefined +) { + const declared = [ + declaredPixelSpacing(tags[0], 1), + declaredPixelSpacing(tags[0], 0), + tags.length > 1 ? distance : Number(tags[0].get(Tags.SpacingBetweenSlices)), + ]; + return declared.map((spacing, axis) => { + if (!spacing || !Number.isFinite(spacing) || spacing <= 0) return false; + if (grid && !numbersMatch(Math.abs(grid.spacing[axis]), spacing)) + return false; + return ( + axis === 2 || + tags.every((entry) => + numbersMatch(declaredPixelSpacing(entry, 1 - axis), spacing) + ) + ); + }); +} + +/** Validate source slice arrangement against the loaded grid. */ +export function dicomSeriesGeometry( + metadata: readonly Iterable<[string, string]>[], + grid?: Grid +) { + const tags = metadata.map((entry) => + entry instanceof Map ? entry : new Map(entry) + ); + if (!tags.length) + return { reason: null, calibratedAxes: [false, false, false] }; + const geometry = + tags.length > 1 + ? seriesGeometry(tags, grid) + : { + reason: tags[0].get(OCT_GEOMETRY_ERROR) ?? null, + distance: undefined, + }; + return { + ...geometry, + calibratedAxes: calibratedAxes(tags, grid, geometry.distance), + }; +} diff --git a/src/enface/EnFaceControlPanel.vue b/src/enface/EnFaceControlPanel.vue index 72b3bbd71..b85a1f05e 100644 --- a/src/enface/EnFaceControlPanel.vue +++ b/src/enface/EnFaceControlPanel.vue @@ -31,6 +31,13 @@ const controlState = computed(() => { axes: [], depthMaximum: 0, projectionReason: reason, + thickness: { + thresholdMaximum: 1, + segmentationReason: reason, + highlightReason: reason, + thicknessReason: reason, + segments: [], + }, } satisfies EnFaceViewControlState; }); function patch(patchSettings: Partial) { diff --git a/src/enface/EnFaceControls.vue b/src/enface/EnFaceControls.vue index 891eea578..290840d9d 100644 --- a/src/enface/EnFaceControls.vue +++ b/src/enface/EnFaceControls.vue @@ -3,6 +3,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 EnFaceThicknessControls from './EnFaceThicknessControls.vue'; import type { EnFaceViewConfig, EnFaceViewControlState } from './store'; const props = defineProps< @@ -11,7 +12,7 @@ const props = defineProps< const emit = defineEmits<{ patch: [settings: Partial]; }>(); -const expandedSections = ref([0]); +const expandedSections = ref([0, 1]); const slabDraft = ref(null); watch( () => [ @@ -152,6 +153,12 @@ function updateAxis(axis: number) { + diff --git a/src/enface/EnFaceMeasurementStatus.vue b/src/enface/EnFaceMeasurementStatus.vue new file mode 100644 index 000000000..90571846f --- /dev/null +++ b/src/enface/EnFaceMeasurementStatus.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/enface/EnFaceThicknessControls.vue b/src/enface/EnFaceThicknessControls.vue new file mode 100644 index 000000000..c893067b1 --- /dev/null +++ b/src/enface/EnFaceThicknessControls.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/enface/EnFaceViewer.vue b/src/enface/EnFaceViewer.vue index 3dd82e300..9455fff5a 100644 --- a/src/enface/EnFaceViewer.vue +++ b/src/enface/EnFaceViewer.vue @@ -4,6 +4,8 @@ import { useViewWindowingRanges } from '@/src/composables/useViewWindowingRanges import { useReferenceLinesStore } from '@/src/referenceLines'; import { slabWindowRanges } from '@/src/core/slab/windowRanges'; import { useEnFaceInput } from './useEnFaceInput'; +import { useEnFaceOccupiedDepth } from './useEnFaceOccupiedDepth'; +import VtkEnFaceLabelmapRepresentation from './VtkEnFaceLabelmapRepresentation.vue'; import VtkSliceView from '@/src/components/vtk/VtkSliceView.vue'; import VtkSliceViewNavigation from '@/src/components/vtk/VtkSliceViewNavigation.vue'; import VtkSliceViewWindowManipulator from '@/src/components/vtk/VtkSliceViewWindowManipulator.vue'; @@ -21,6 +23,7 @@ import DicomQuickInfoButton from '@/src/components/DicomQuickInfoButton.vue'; import ReasonedAction from '@/src/components/ReasonedAction.vue'; import EnFaceControlPanel from './EnFaceControlPanel.vue'; import EnFaceStatus from './EnFaceStatus.vue'; +import EnFaceMeasurementStatus from './EnFaceMeasurementStatus.vue'; import { useEnFaceViewStore } from './store'; const props = defineProps<{ viewId: string }>(); @@ -70,10 +73,22 @@ const windowingManipulatorProps = computed(() => ({ const stateMessage = computed( () => controls.value.projectionReason || fatalContextError.value ); +const { + overlay: thinRegions, + controls: thicknessControls, + measuring, + message: measurementMessage, +} = useEnFaceOccupiedDepth(viewId, { + source, + slab: specification, + geometry, + blockedReason: stateMessage, +}); const enFaceStore = useEnFaceViewStore(); const controlState = computed(() => ({ ...controls.value, projectionReason: stateMessage.value, + thickness: thicknessControls.value, })); watch( [viewId, currentImageID, controlState], @@ -88,7 +103,7 @@ watch(
+ +