From f361e75b43551553b6ceef3eba3ca1f32bf8690d Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 9 Nov 2026 17:54:25 -0500 Subject: [PATCH] feat: project segment footprints in en face views --- src/enface/EnFaceControlPanel.vue | 1 + src/enface/EnFaceControls.vue | 8 +- src/enface/EnFaceFootprintControls.vue | 107 ++++++ src/enface/EnFaceSegmentFootprint.vue | 65 ++++ src/enface/EnFaceSegmentationProjection.vue | 79 +++++ src/enface/EnFaceViewer.vue | 12 + .../VtkEnFaceLabelmapRepresentation.vue | 2 +- src/enface/__tests__/controls.spec.ts | 23 +- src/enface/__tests__/inputCalibration.spec.ts | 4 + src/enface/__tests__/restoreSelection.spec.ts | 1 + src/enface/__tests__/store.spec.ts | 24 +- src/enface/store.ts | 10 + src/enface/useEnFaceLabelmaps.ts | 10 +- src/enface/useOccupiedDepthInput.ts | 35 +- src/enface/useThinRegionAppearance.ts | 10 +- src/io/state-file/schema.ts | 3 + .../measurements/occupiedDepth.worker.ts | 21 +- .../measurements/useOccupiedDepth.ts | 42 ++- .../enface-segmentation-projection.e2e.ts | 335 ++++++++++++++++++ tests/specs/oct-occupied-depth.e2e.ts | 17 +- tests/specs/octTestUtils.ts | 16 + 21 files changed, 760 insertions(+), 65 deletions(-) create mode 100644 src/enface/EnFaceFootprintControls.vue create mode 100644 src/enface/EnFaceSegmentFootprint.vue create mode 100644 src/enface/EnFaceSegmentationProjection.vue create mode 100644 tests/specs/enface-segmentation-projection.e2e.ts diff --git a/src/enface/EnFaceControlPanel.vue b/src/enface/EnFaceControlPanel.vue index 07a44e71a..7188a3ccb 100644 --- a/src/enface/EnFaceControlPanel.vue +++ b/src/enface/EnFaceControlPanel.vue @@ -31,6 +31,7 @@ const controlState = computed(() => { axes: [], depthMaximum: 0, projectionReason: reason, + footprints: { segments: [], segmentationReason: reason }, thickness: { thresholdMaximum: 1, segmentationReason: reason, diff --git a/src/enface/EnFaceControls.vue b/src/enface/EnFaceControls.vue index 290840d9d..b222d863c 100644 --- a/src/enface/EnFaceControls.vue +++ b/src/enface/EnFaceControls.vue @@ -4,6 +4,7 @@ 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 EnFaceFootprintControls from './EnFaceFootprintControls.vue'; import type { EnFaceViewConfig, EnFaceViewControlState } from './store'; const props = defineProps< @@ -12,7 +13,7 @@ const props = defineProps< const emit = defineEmits<{ patch: [settings: Partial]; }>(); -const expandedSections = ref([0, 1]); +const expandedSections = ref([0, 1, 2]); const slabDraft = ref(null); watch( () => [ @@ -153,6 +154,11 @@ function updateAxis(axis: number) { + +import ReasonedAction from '@/src/components/ReasonedAction.vue'; +import ColorDot from '@/src/components/ColorDot.vue'; +import type { EnFaceViewConfig, ThicknessControlState } from './store'; + +const props = defineProps< + Pick & { + settings: EnFaceViewConfig; + } +>(); +const emit = defineEmits<{ patch: [settings: Partial] }>(); +function updateEnabled(enabled: boolean | null) { + const ids = props.settings.footprintSegmentIds; + emit('patch', { + showFootprints: !!enabled, + footprintSegmentIds: ids.length + ? ids + : props.segments.slice(0, 1).map((segment) => segment.value), + }); +} +const scopes = [ + { title: 'Current slab', value: 'slab' }, + { title: 'Entire volume', value: 'volume' }, +]; + + + + + diff --git a/src/enface/EnFaceSegmentFootprint.vue b/src/enface/EnFaceSegmentFootprint.vue new file mode 100644 index 000000000..76f6ddc66 --- /dev/null +++ b/src/enface/EnFaceSegmentFootprint.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/enface/EnFaceSegmentationProjection.vue b/src/enface/EnFaceSegmentationProjection.vue new file mode 100644 index 000000000..8809deb7d --- /dev/null +++ b/src/enface/EnFaceSegmentationProjection.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/enface/EnFaceViewer.vue b/src/enface/EnFaceViewer.vue index 9455fff5a..66d004c7f 100644 --- a/src/enface/EnFaceViewer.vue +++ b/src/enface/EnFaceViewer.vue @@ -24,6 +24,7 @@ import ReasonedAction from '@/src/components/ReasonedAction.vue'; import EnFaceControlPanel from './EnFaceControlPanel.vue'; import EnFaceStatus from './EnFaceStatus.vue'; import EnFaceMeasurementStatus from './EnFaceMeasurementStatus.vue'; +import EnFaceSegmentationProjection from './EnFaceSegmentationProjection.vue'; import { useEnFaceViewStore } from './store'; const props = defineProps<{ viewId: string }>(); @@ -89,6 +90,10 @@ const controlState = computed(() => ({ ...controls.value, projectionReason: stateMessage.value, thickness: thicknessControls.value, + footprints: { + segments: thicknessControls.value.segments, + segmentationReason: thicknessControls.value.segmentationReason, + }, })); watch( [viewId, currentImageID, controlState], @@ -158,6 +163,13 @@ watch( :plane-normal="geometry.planeNormal" /> + diff --git a/src/enface/VtkEnFaceLabelmapRepresentation.vue b/src/enface/VtkEnFaceLabelmapRepresentation.vue index e2bbcd724..4a840707f 100644 --- a/src/enface/VtkEnFaceLabelmapRepresentation.vue +++ b/src/enface/VtkEnFaceLabelmapRepresentation.vue @@ -15,7 +15,7 @@ const props = defineProps<{ geometry: MeasurementPlaneGeometry | null; measurement: OccupiedDepthResult | null; micronsPerVoxel: number; - threshold: number; + threshold: number | null; appearance: LabelmapAppearance | null; }>(); const { geometry, measurement, micronsPerVoxel, threshold, appearance } = diff --git a/src/enface/__tests__/controls.spec.ts b/src/enface/__tests__/controls.spec.ts index e96663ded..eab64ab11 100644 --- a/src/enface/__tests__/controls.spec.ts +++ b/src/enface/__tests__/controls.spec.ts @@ -28,6 +28,15 @@ enableAutoUnmount(afterEach); function mountControls(overrides = {}) { const settings = reactive({ ...defaultEnFaceViewConfig(), ...overrides }); + const thickness = { + thresholdMaximum: 200, + segmentationReason: null, + highlightReason: null, + thicknessReason: null, + segments: [{ value: 'retina', title: 'Retinal layer', color: '#ff0000' }], + segmentId: 'retina', + highlightThin: true, + }; return mount(EnFaceControls, { attachTo: document.body, props: { @@ -41,17 +50,8 @@ function mountControls(overrides = {}) { Object.assign(settings, patch), depthMaximum: 221, projectionReason: null, - thickness: { - thresholdMaximum: 200, - segmentationReason: null, - highlightReason: null, - thicknessReason: null, - segments: [ - { value: 'retina', title: 'Retinal layer', color: '#ff0000' }, - ], - segmentId: 'retina', - highlightThin: true, - }, + thickness, + footprints: thickness, }, global: { plugins: [createVuetify()] }, }); @@ -471,6 +471,7 @@ function mountImageControls(spacing = 0.01) { input.source.currentImageID.value ), thickness: thickness.controls.value, + footprints: thickness.controls.value, }); }, }); diff --git a/src/enface/__tests__/inputCalibration.spec.ts b/src/enface/__tests__/inputCalibration.spec.ts index 83d907b2a..2a3b3bac5 100644 --- a/src/enface/__tests__/inputCalibration.spec.ts +++ b/src/enface/__tests__/inputCalibration.spec.ts @@ -90,6 +90,10 @@ async function mountInput(id: string) { h(EnFaceControls, { ...projection.controls.value, settings: input.settings.value, + footprints: { + segments: input.segmentOptions.value, + segmentationReason: input.segmentationReason.value, + }, thickness: { thresholdMaximum: 200, segmentationReason: input.segmentationReason.value, diff --git a/src/enface/__tests__/restoreSelection.spec.ts b/src/enface/__tests__/restoreSelection.spec.ts index 5a4eeb51a..95d9f8b5e 100644 --- a/src/enface/__tests__/restoreSelection.spec.ts +++ b/src/enface/__tests__/restoreSelection.spec.ts @@ -158,6 +158,7 @@ it('keeps the projection ready and restores the non-first segment after deferred ...projection.controls.value, settings: useEnFaceViewStore().configFor('view', 'oct'), thickness: thickness.controls.value, + footprints: thickness.controls.value, }); }, }), diff --git a/src/enface/__tests__/store.spec.ts b/src/enface/__tests__/store.spec.ts index d0515c772..8cd617674 100644 --- a/src/enface/__tests__/store.spec.ts +++ b/src/enface/__tests__/store.spec.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import JSZip from 'jszip'; -import { useEnFaceViewStore } from '@/src/enface/store'; +import { + defaultEnFaceViewConfig, + useEnFaceViewStore, +} from '@/src/enface/store'; import { ManifestSchema, type StateFile } from '@/src/io/state-file/schema'; import { parseNamedLayouts } from '@/src/utils/layoutParsing'; import { computeEffectiveView } from '@/src/core/views/effectiveView'; @@ -32,6 +35,9 @@ describe('En face view settings', () => { depthStart: 20, depthEnd: 40, thresholdMicrons: 35, + showFootprints: true, + footprintSegmentIds: ['saved-segment'], + footprintScope: 'volume', }); const state: StateFile = { zip: new JSZip(), @@ -72,7 +78,7 @@ describe('En face view settings', () => { 'restored', settings, { oct: 'new-oct' }, - {} + { 'saved-segment': 'restored-segment' } ); expect(store.configFor('restored', 'new-oct')).toMatchObject({ method: 'sum', @@ -80,6 +86,9 @@ describe('En face view settings', () => { depthEnd: 40, thresholdMicrons: 35, highlightThin: false, + showFootprints: true, + footprintSegmentIds: ['restored-segment'], + footprintScope: 'volume', }); expect(store.configFor('restored', 'new-oct').segmentId).toBeNull(); expect(useWindowingStore().getConfig('restored', 'new-oct')).toMatchObject({ @@ -128,13 +137,12 @@ describe('En face view settings', () => { const store = useEnFaceViewStore(); const saved = (segmentId: string) => ({ enFace: { - axis: 1 as const, - method: 'mean' as const, - depthStart: 0, - depthEnd: null, + ...defaultEnFaceViewConfig(), thresholdMicrons: 35, highlightThin: true, segmentId, + showFootprints: true, + footprintSegmentIds: [segmentId], }, }); store.deserialize( @@ -144,6 +152,10 @@ describe('En face view settings', () => { ); expect(store.configFor('view', 'kept').segmentId).toBe('chosen'); expect(store.configFor('view', 'dropped').segmentId).toBeNull(); + expect(store.configFor('view', 'kept').footprintSegmentIds).toEqual([ + 'chosen', + ]); + expect(store.configFor('view', 'dropped').footprintSegmentIds).toEqual([]); }); it('resolves custom enface layouts without treating the projection as a slice', () => { diff --git a/src/enface/store.ts b/src/enface/store.ts index 1a5884955..8618304ca 100644 --- a/src/enface/store.ts +++ b/src/enface/store.ts @@ -18,6 +18,9 @@ export type EnFaceViewConfig = { highlightThin: boolean; thresholdMicrons: number; segmentId: string | null; + showFootprints: boolean; + footprintSegmentIds: string[]; + footprintScope: 'slab' | 'volume'; }; export type ThicknessControlState = { @@ -35,6 +38,7 @@ export type EnFaceViewControlState = { depthMaximum: number; projectionReason: string | null; thickness: ThicknessControlState; + footprints: Pick; }; export const defaultEnFaceViewConfig = (): EnFaceViewConfig => ({ @@ -45,6 +49,9 @@ export const defaultEnFaceViewConfig = (): EnFaceViewConfig => ({ highlightThin: false, thresholdMicrons: 100, segmentId: null, + showFootprints: false, + footprintSegmentIds: [], + footprintScope: 'slab', }); export const useEnFaceViewStore = defineStore('enface-view', () => { @@ -106,6 +113,9 @@ export const useEnFaceViewStore = defineStore('enface-view', () => { if (!saved) return; Object.assign(configFor(viewID, dataID), saved, { segmentId: (saved.segmentId && segmentIdMap[saved.segmentId]) || null, + footprintSegmentIds: saved.footprintSegmentIds + .map((id) => segmentIdMap[id]) + .filter((id): id is string => !!id), }); }); } diff --git a/src/enface/useEnFaceLabelmaps.ts b/src/enface/useEnFaceLabelmaps.ts index 659c60eb6..4e0438315 100644 --- a/src/enface/useEnFaceLabelmaps.ts +++ b/src/enface/useEnFaceLabelmaps.ts @@ -31,7 +31,7 @@ const sameGeometry = ( export function useEnFaceLabelmaps( geometry: MaybeRef, measurement: MaybeRef, - threshold: MaybeRef, + threshold: MaybeRef, micronsPerVoxel: MaybeRef ) { const image = vtkImageData.newInstance(); @@ -42,9 +42,11 @@ export function useEnFaceLabelmaps( image.getPointData().setScalars(scalars); const cutoff = computed(() => { const result = unref(measurement); - return result - ? thinCountCutoff(result, unref(threshold), unref(micronsPerVoxel)) - : 0; + const value = unref(threshold); + if (!result) return 0; + return value === null + ? result.maximumCount + : thinCountCutoff(result, value, unref(micronsPerVoxel)); }); // Threshold drags rebuild the plane only when the count cutoff moves. const pixels = computed(() => { diff --git a/src/enface/useOccupiedDepthInput.ts b/src/enface/useOccupiedDepthInput.ts index 9bb19b00a..06bbe5dcf 100644 --- a/src/enface/useOccupiedDepthInput.ts +++ b/src/enface/useOccupiedDepthInput.ts @@ -29,7 +29,12 @@ export type OccupiedDepthProjection = { /** Binds the selected segment mask and calibrated slab to a measurement request. */ export function useOccupiedDepthInput( viewId: MaybeRef, - { source, slab, blockedReason }: OccupiedDepthProjection + { source, slab, blockedReason }: OccupiedDepthProjection, + selection?: { + segmentId: MaybeRef; + enabled: MaybeRef; + calibrated: boolean; + } ) { const { currentImageID, currentImageData } = source; const segmentationStore = useSegmentationStore(); @@ -50,7 +55,9 @@ export function useOccupiedDepthInput( }); const storedSegment = computed(() => - segmentsRegistry.getSegment(settings.value.segmentId) + segmentsRegistry.getSegment( + selection ? unref(selection.segmentId) : settings.value.segmentId + ) ); const segmentOptions = computed(() => { if (!currentImageID.value) return []; @@ -185,17 +192,20 @@ export function useOccupiedDepthInput( : Math.hypot(...image.getDirection().slice(axis * 3, axis * 3 + 3)); return Math.abs(image.getSpacing()[axis]) * scale * 1000; }); + const calibrationReason = computed(() => { + if (selection?.calibrated === false) return null; + if (support.value.reason) return support.value.reason; + if (support.value.calibratedAxes?.[slabAxis.value] === false) + return 'The projection axis has no reliable declared voxel spacing; occupied depth is unavailable.'; + if (!Number.isFinite(micronsPerVoxel.value) || micronsPerVoxel.value <= 0) + return 'The projection axis has invalid voxel spacing; occupied depth is unavailable.'; + return null; + }); const highlightReason = computed( () => segmentationReason.value || (!selectedBinding.value ? maskStorageReason.value : null) || - support.value.reason || - (support.value.calibratedAxes?.[slabAxis.value] === false - ? 'The projection axis has no reliable declared voxel spacing; occupied depth is unavailable.' - : null) || - (!Number.isFinite(micronsPerVoxel.value) || micronsPerVoxel.value <= 0 - ? 'The projection axis has invalid voxel spacing; occupied depth is unavailable.' - : null) + calibrationReason.value ); const thicknessReason = computed( () => @@ -228,7 +238,12 @@ export function useOccupiedDepthInput( }); const request = computed((): OccupiedDepthRequest | null => { - if (!highlightThin.value) return null; + if ( + !(selection + ? unref(selection.enabled) && !!storedSegment.value + : highlightThin.value) + ) + return null; void volumeRevision.value; const image = currentImageData.value; const mask = measurementMask.value; diff --git a/src/enface/useThinRegionAppearance.ts b/src/enface/useThinRegionAppearance.ts index 810bfa33b..019d44c55 100644 --- a/src/enface/useThinRegionAppearance.ts +++ b/src/enface/useThinRegionAppearance.ts @@ -4,7 +4,10 @@ import { useSegmentationStore } from '@/src/segmentation/store'; import { labelmapAppearance } from '@/src/segmentation/rendering/display'; /** The selected mask uses its source slice's normal fill and outline settings. */ -export function useThinRegionAppearance(maskId: MaybeRef>) { +export function useThinRegionAppearance( + maskId: MaybeRef>, + purpose = 'highlight thin regions' +) { const segmentationStore = useSegmentationStore(); return computed(() => { const id = unref(maskId); @@ -15,13 +18,12 @@ export function useThinRegionAppearance(maskId: MaybeRef>) { const appearance = labelmapAppearance(descriptor, segmentation); let reason: string | null = null; if (!descriptor.visible) - reason = 'Show the selected segment to highlight thin regions.'; + reason = `Show the selected segment to ${purpose}.`; else if ( appearance.fillOpacity <= 0 && (appearance.outlineThickness <= 0 || appearance.outlineOpacity <= 0) ) - reason = - 'Increase segmentation fill opacity or enable an outline to highlight thin regions.'; + reason = `Increase segmentation fill opacity or enable an outline to ${purpose}.`; return { ...appearance, reason }; }); } diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index 224c56bed..cba06a497 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -290,6 +290,9 @@ const EnFaceViewConfig = z.object({ highlightThin: z.boolean().default(false), thresholdMicrons: z.number().nonnegative().default(100), segmentId: z.string().nullable().default(null), + showFootprints: z.boolean().default(false), + footprintSegmentIds: z.array(z.string()).default([]), + footprintScope: z.enum(['slab', 'volume']).default('slab'), }) satisfies z.ZodType; const ViewConfig = z.object({ diff --git a/src/segmentation/measurements/occupiedDepth.worker.ts b/src/segmentation/measurements/occupiedDepth.worker.ts index 700a3550d..5c8518fb0 100644 --- a/src/segmentation/measurements/occupiedDepth.worker.ts +++ b/src/segmentation/measurements/occupiedDepth.worker.ts @@ -4,23 +4,34 @@ import { type OccupiedDepthInput, } from '@/src/segmentation/measurements/occupiedDepth'; -let measure: ReturnType | undefined; +const measurements = new Map< + string, + ReturnType +>(); const api = { - /** Without a mask, reuses the one this worker was last sent. */ + /** Without a mask, reuses the snapshot for this segment. */ measure( input: Omit, - mask?: OccupiedDepthInput['mask'] + mask?: OccupiedDepthInput['mask'], + segment = '' ) { if (mask) { // A rejected replacement must not leave the previous mask measurable. - measure = undefined; - measure = createOccupiedDepthMeasurer(mask); + measurements.delete(segment); + measurements.set(segment, createOccupiedDepthMeasurer(mask)); } + const measure = measurements.get(segment); if (!measure) throw new Error('Occupied depth has no measurable mask.'); const result = measure(input); return Comlink.transfer(result, [result.counts.buffer as ArrayBuffer]); }, + retain(segments: string[]) { + const retained = new Set(segments); + measurements.forEach((_measurement, segment) => { + if (!retained.has(segment)) measurements.delete(segment); + }); + }, }; export type OccupiedDepthWorkerApi = typeof api; diff --git a/src/segmentation/measurements/useOccupiedDepth.ts b/src/segmentation/measurements/useOccupiedDepth.ts index b09c9bcdc..e2b56a02d 100644 --- a/src/segmentation/measurements/useOccupiedDepth.ts +++ b/src/segmentation/measurements/useOccupiedDepth.ts @@ -39,13 +39,24 @@ const spawnMeasurementWorker = () => /** A view's worker, sent each mask revision once and reusing it across slabs. */ export function createWorkerMeasurer(spawn = spawnMeasurementWorker) { const host = createWorkerHost(spawn); - let sent: { api: unknown; mask: OccupiedDepthInput['mask'] } | undefined; + const sent = new Map< + string, + { api: unknown; mask: OccupiedDepthInput['mask'] } + >(); return { - measure: ({ mask, dimensions, axis, first, last }: OccupiedDepthInput) => + measure: ({ + mask, + dimensions, + axis, + first, + last, + segment = '', + }: OccupiedDepthInput & { segment?: string }) => host.call((api) => { // A respawned worker holds no mask. - const fresh = sent?.api !== api || sent?.mask !== mask; - sent = { api, mask }; + const previous = sent.get(segment); + const fresh = previous?.api !== api || previous?.mask !== mask; + sent.set(segment, { api, mask }); const values = fresh ? new Uint8Array(mask.values) : undefined; return api.measure( { dimensions: [...dimensions], axis, first, last }, @@ -57,17 +68,29 @@ export function createWorkerMeasurer(spawn = spawnMeasurementWorker) { extent: [...mask.extent], }, [values.buffer] - ) + ), + segment ); }), + retain(segments: string[]) { + if (!sent.size) return; + const retained = new Set(segments); + sent.forEach((_snapshot, segment) => { + if (!retained.has(segment)) sent.delete(segment); + }); + void host.call((api) => api.retain(segments)).catch(() => {}); + }, release() { - sent = undefined; + sent.clear(); host.terminate(); }, }; } -export type OccupiedDepthMeasurer = ReturnType; +export type OccupiedDepthMeasurer = Pick< + ReturnType, + 'measure' | 'release' +>; /** Measures the latest request, keeping a compatible result while it updates. */ export function useOccupiedDepth( @@ -121,7 +144,10 @@ export function useOccupiedDepth( }, { immediate: true, flush: 'pre' } ); - onScopeDispose(measurer.release); + onScopeDispose(() => { + pending = null; + measurer.release(); + }); const accepted = computed(() => { const input = unref(request); return input && latest.value && compatible(latest.value.input, input) diff --git a/tests/specs/enface-segmentation-projection.e2e.ts b/tests/specs/enface-segmentation-projection.e2e.ts new file mode 100644 index 000000000..5796780c0 --- /dev/null +++ b/tests/specs/enface-segmentation-projection.e2e.ts @@ -0,0 +1,335 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import JSZip from 'jszip'; +import { createNrrdVolume, createVtiVolume } from '../fixtures/oct/volumes'; +import { volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { + makeTempDir, + overlayOption, + SESSION_SAVE_TIMEOUT, + waitForDownload, +} from './utils'; +import { + PANEL, + enFacePixel, + expandEnFaceSection, + openEnFaceControls, + openEnFaceSidebarControls, + selectSetting, + setEnFaceWindow, + setSlabRange, + waitForEnFaceReady, +} from './octTestUtils'; +import { registerOctTestHooks } from './octInteractionTestUtils'; +import { openAnnotationSegments, segmentRow } from './segmentationTestUtils'; + +const SIZE = 12; +const STEM = 'enface-footprints-' + process.pid; +const SEGMENTS = ['Red columns', 'Blue columns']; +const RED = [2, 3] as const; +const BLUE = [7, 8] as const; +const OVERLAP = [4, 6] as const; +const DEEP = [9, 2] as const; +const EMPTY = [10, 10] as const; +const TOGGLE = '[data-testid="enface-footprints-toggle"]'; +const SELECTION = '[data-testid="enface-footprint-segments"]'; +const SCOPE = 'enface-footprint-scope'; +type PixelKind = 'gray' | 'red' | 'blue' | 'overlap'; +type Sample = readonly [readonly [number, number], PixelKind]; + +const maskValues = (inside: (x: number, y: number, z: number) => boolean) => + Uint16Array.from({ length: SIZE ** 3 }, (_, index) => + Number( + inside( + index % SIZE, + Math.floor(index / SIZE) % SIZE, + Math.floor(index / SIZE ** 2) + ) + ) + ); + +async function openFootprintScene() { + const directory = makeTempDir(STEM); + fs.writeFileSync( + path.join(directory, 'parent.vti'), + createVtiVolume({ + values: new Uint16Array(SIZE ** 3).fill(100), + dimensions: [SIZE, SIZE, SIZE], + type: 'UInt16', + }) + ); + const zip = new JSZip(); + const masks = [ + { + id: 'red', + name: SEGMENTS[0], + color: [255, 0, 0, 255], + values: maskValues( + (x, y, z) => x >= 1 && x <= 5 && y >= 1 && y <= 5 && z >= 2 && z <= 8 + ), + }, + { + id: 'blue', + name: SEGMENTS[1], + color: [0, 0, 255, 255], + values: maskValues( + (x, y, z) => + (x >= 4 && x <= 8 && y >= 6 && y <= 10 && z >= 5 && z <= 10) || + (x >= 8 && x <= 10 && y >= 9 && y <= 10 && z >= 1 && z <= 3) + ), + }, + ]; + masks.forEach(({ id, values }) => + zip.file( + `${id}.nrrd`, + createNrrdVolume({ + dimensions: [SIZE, SIZE, SIZE], + spacing: [1, 1, 1], + scalars: values, + }) + ) + ); + zip.file( + 'manifest.json', + JSON.stringify({ + version: '7.0.0', + dataSources: [{ id: 0, type: 'uri', uri: `/tmp/${STEM}/parent.vti` }], + segments: masks.map(({ id, name, color }) => ({ id, name, color })), + selectedSegment: 'red', + segmentations: [ + { + id: 'segmentation', + name: 'Columns', + parentImage: '0', + fillOpacity: 0.5, + outlineOpacity: 0, + outlineThickness: 0, + order: masks.map(({ id }) => `${id}-mask`), + masks: masks.map(({ id }) => ({ + id: `${id}-mask`, + segmentId: id, + representations: { + labelmap: { + path: `${id}.nrrd`, + extent: [0, SIZE - 1, 0, SIZE - 1, 0, SIZE - 1], + }, + }, + })), + }, + ], + viewByID: { + enface: { + id: 'enface', + name: 'En face', + type: 'EnFace', + dataID: '0', + config: { + '0': { + enFace: { + axis: 1, + method: 'mean', + depthStart: 0, + depthEnd: null, + highlightThin: true, + thresholdMicrons: 1000, + segmentId: 'red', + }, + }, + }, + }, + }, + layout: { direction: 'row', items: [{ type: 'slot', slotIndex: 0 }] }, + layoutSlots: ['enface'], + activeView: 'enface', + }) + ); + fs.writeFileSync( + path.join(directory, 'columns.volview.zip'), + await zip.generateAsync({ type: 'nodebuffer' }) + ); + await volViewPage.open(`?urls=[tmp/${STEM}/columns.volview.zip]`); + await waitForEnFaceReady(); + await setEnFaceWindow(200, 100); + await openEnFaceControls(); +} + +async function openFootprintSection(scope = PANEL) { + const section = $(scope).$( + '[data-testid="enface-footprints-section-toggle"]' + ); + await section.scrollIntoView({ block: 'center', behavior: 'instant' }); + await section.waitForClickable(); + if ((await section.getAttribute('aria-expanded')) !== 'true') + await section.click(); + await expect(section).toHaveAttribute('aria-expanded', 'true'); + await section.$('..').waitForStable(); +} + +async function setFootprints(enabled: boolean, scope = PANEL) { + await openFootprintSection(scope); + const toggle = $(scope).$(TOGGLE); + await toggle.scrollIntoView({ block: 'center', behavior: 'instant' }); + await expect(toggle.$('input')).toBeEnabled(); + if ((await toggle.$('input').isSelected()) !== enabled) + await toggle.$('label').click(); + if (enabled) await expect(toggle.$('input')).toBeSelected(); + else await expect(toggle.$('input')).not.toBeSelected(); +} + +async function setProjectedSegments(names: string[], scope = PANEL) { + await openFootprintSection(scope); + const selector = $(scope).$(SELECTION); + await selector.scrollIntoView({ block: 'center', behavior: 'instant' }); + await selector.waitForClickable(); + await selector.click(); + for (const name of SEGMENTS) { + const option = overlayOption(name); + await option.waitForClickable(); + if ( + ((await option.getAttribute('aria-selected')) === 'true') !== + names.includes(name) + ) + await option.click(); + } + await browser.keys('Escape'); + await overlayOption(SEGMENTS[0]).waitForDisplayed({ reverse: true }); +} + +function pixelMatches([red, green, blue]: number[], kind: PixelKind) { + const matches = { + gray: red > 20 && red === green && green === blue, + red: red > green + 30 && red > blue + 30, + blue: blue > red + 30 && blue > green + 30, + overlap: red > green + 20 && blue > green + 20, + }; + return matches[kind]; +} + +async function expectPixels(samples: readonly Sample[]) { + await waitForEnFaceReady(); + await browser.waitUntil( + async () => { + const matches = await Promise.all( + samples.map(async ([point, kind]) => + pixelMatches(await enFacePixel(...point), kind) + ) + ); + return matches.every(Boolean); + }, + { + timeoutMsg: `Expected projected segmentation pixels: ${JSON.stringify(samples)}`, + } + ); +} + +describe('En face segmentation projections', () => { + registerOctTestHooks(); + + it('projects tall columns, overlaps and deep objects with slab and visibility controls', async () => { + await openFootprintScene(); + await expectPixels([ + [RED, 'gray'], + [BLUE, 'gray'], + [DEEP, 'gray'], + ]); + await setFootprints(true); + await setProjectedSegments(SEGMENTS); + await expectPixels([ + [RED, 'red'], + [BLUE, 'blue'], + [OVERLAP, 'overlap'], + [DEEP, 'blue'], + [EMPTY, 'gray'], + ]); + + await expandEnFaceSection('projection'); + await setSlabRange(1, 8); + await expectPixels([ + [RED, 'red'], + [BLUE, 'blue'], + [DEEP, 'gray'], + ]); + await openFootprintSection(); + await selectSetting(SCOPE, 'Entire volume'); + await expectPixels([[DEEP, 'blue']]); + await selectSetting(SCOPE, 'Current slab'); + await expectPixels([[DEEP, 'gray']]); + + await browser.keys('Escape'); + await openAnnotationSegments(); + const red = await segmentRow(SEGMENTS[0]); + await red.$('button:has(i.mdi-eye)').click(); + await expectPixels([ + [RED, 'gray'], + [OVERLAP, 'blue'], + [BLUE, 'blue'], + ]); + await red.$('button:has(i.mdi-eye-off)').click(); + await expectPixels([ + [RED, 'red'], + [OVERLAP, 'overlap'], + ]); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + + it('shares selection and scope with Rendering controls and restores them from a session', async () => { + await openFootprintScene(); + await setFootprints(true); + await setProjectedSegments([SEGMENTS[1]]); + await expandEnFaceSection('projection'); + await setSlabRange(1, 8); + await openFootprintSection(); + await selectSetting(SCOPE, 'Entire volume'); + await expectPixels([ + [RED, 'gray'], + [BLUE, 'blue'], + [DEEP, 'blue'], + ]); + + const sidebar = await openEnFaceSidebarControls(); + await openFootprintSection(sidebar); + await expect($(sidebar).$(TOGGLE + ' input')).toBeSelected(); + await expect($(sidebar).$(SELECTION)).toHaveText( + expect.stringContaining(SEGMENTS[1]) + ); + await expect($(sidebar).$(`[data-testid="${SCOPE}"] input`)).toHaveValue( + 'Entire volume' + ); + await setFootprints(false, sidebar); + await expectPixels([ + [BLUE, 'gray'], + [DEEP, 'gray'], + ]); + await openEnFaceControls(); + await openFootprintSection(); + await expect($(PANEL).$(TOGGLE + ' input')).not.toBeSelected(); + await setFootprints(true); + await browser.keys('Escape'); + + const saved = await volViewPage.saveSession(); + await waitForDownload(path.join(TEMP_DIR, saved), SESSION_SAVE_TIMEOUT); + await volViewPage.open(`?urls=[tmp/${saved}]`); + await waitForEnFaceReady(); + await expectPixels([ + [RED, 'gray'], + [BLUE, 'blue'], + [DEEP, 'blue'], + ]); + await openEnFaceControls(); + await openFootprintSection(); + await expect($(PANEL).$(TOGGLE + ' input')).toBeSelected(); + await expect($(PANEL).$(SELECTION)).toHaveText( + expect.stringContaining(SEGMENTS[1]) + ); + await expect($(PANEL).$(`[data-testid="${SCOPE}"] input`)).toHaveValue( + 'Entire volume' + ); + await selectSetting(SCOPE, 'Current slab'); + await expectPixels([ + [BLUE, 'blue'], + [DEEP, 'gray'], + ]); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); +}); diff --git a/tests/specs/oct-occupied-depth.e2e.ts b/tests/specs/oct-occupied-depth.e2e.ts index 7a2a486c7..6ce0576a1 100644 --- a/tests/specs/oct-occupied-depth.e2e.ts +++ b/tests/specs/oct-occupied-depth.e2e.ts @@ -2,9 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import JSZip from 'jszip'; import { createNrrdVolume, createVtiVolume } from '../fixtures/oct/volumes'; -import { Key } from 'webdriverio'; import { - PANEL, enFaceAppearance, enFacePixel, waitForEnFaceReady, @@ -15,6 +13,7 @@ import { slabThumb, selectEnFace, openEnFaceControls, + openEnFaceSidebarControls, selectSetting, THRESHOLD, THRESHOLD_SLIDER, @@ -30,7 +29,6 @@ import { openThroughDialog, waitForDownload, SESSION_SAVE_TIMEOUT, - waitForModuleTransitions, } from './utils'; import { setSegmentationDisplaySlider as setDisplaySlider } from './segmentationTestUtils'; import { @@ -277,18 +275,7 @@ describe('Retinal OCT occupied-depth', () => { await expectHighlight(false); await setThicknessThreshold(31); await expectHighlight(true); - await browser.keys(Key.Escape); - await $(PANEL).waitForDisplayed({ reverse: true }); - await volViewPage.renderingModuleTab.click(); - await waitForModuleTransitions(); - const toggle = $('[data-testid="enface-rendering-toggle"]'); - await toggle.scrollIntoView({ block: 'center', behavior: 'instant' }); - await toggle.waitForClickable(); - if ((await toggle.getAttribute('aria-expanded')) !== 'true') - await toggle.click(); - const sidebar = $('[data-testid="enface-sidebar-controls"]'); - await sidebar.waitForDisplayed(); - await toggle.$('..').waitForStable(); + const sidebar = $(await openEnFaceSidebarControls()); for (const selector of [ '[data-testid="enface-thin-highlight"] input', '[data-testid="enface-segmentation-segment"] input', diff --git a/tests/specs/octTestUtils.ts b/tests/specs/octTestUtils.ts index 4693d4066..858c00fd5 100644 --- a/tests/specs/octTestUtils.ts +++ b/tests/specs/octTestUtils.ts @@ -211,6 +211,22 @@ export async function openEnFaceControls(view?: WebdriverIO.Element) { await panel.waitForStable(); } +export async function openEnFaceSidebarControls() { + await browser.keys(Key.Escape); + await $(PANEL).waitForDisplayed({ reverse: true }); + await volViewPage.renderingModuleTab.click(); + await waitForModuleTransitions(); + const toggle = $('[data-testid="enface-rendering-toggle"]'); + await toggle.scrollIntoView({ block: 'center', behavior: 'instant' }); + await toggle.waitForClickable(); + if ((await toggle.getAttribute('aria-expanded')) !== 'true') + await toggle.click(); + const sidebar = '[data-testid="enface-sidebar-controls"]'; + await $(sidebar).waitForDisplayed(); + await toggle.$('..').waitForStable(); + return sidebar; +} + export async function setThicknessThreshold(value: number) { await expandEnFaceSection('thickness'); const input = $(THRESHOLD);