Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/enface/EnFaceControlPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const controlState = computed(() => {
axes: [],
depthMaximum: 0,
projectionReason: reason,
footprints: { segments: [], segmentationReason: reason },
thickness: {
thresholdMaximum: 1,
segmentationReason: reason,
Expand Down
8 changes: 7 additions & 1 deletion src/enface/EnFaceControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -12,7 +13,7 @@ const props = defineProps<
const emit = defineEmits<{
patch: [settings: Partial<EnFaceViewConfig>];
}>();
const expandedSections = ref([0, 1]);
const expandedSections = ref([0, 1, 2]);
const slabDraft = ref<number[] | null>(null);
watch(
() => [
Expand Down Expand Up @@ -153,6 +154,11 @@ function updateAxis(axis: number) {
</ReasonedAction>
</v-expansion-panel-text>
</v-expansion-panel>
<EnFaceFootprintControls
v-bind="footprints"
:settings="settings"
@patch="emit('patch', $event)"
/>
<EnFaceThicknessControls
v-bind="thickness"
:settings="settings"
Expand Down
107 changes: 107 additions & 0 deletions src/enface/EnFaceFootprintControls.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script setup lang="ts">
import ReasonedAction from '@/src/components/ReasonedAction.vue';
import ColorDot from '@/src/components/ColorDot.vue';
import type { EnFaceViewConfig, ThicknessControlState } from './store';

const props = defineProps<
Pick<ThicknessControlState, 'segments' | 'segmentationReason'> & {
settings: EnFaceViewConfig;
}
>();
const emit = defineEmits<{ patch: [settings: Partial<EnFaceViewConfig>] }>();
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' },
];
</script>

<template>
<v-expansion-panel bg-color="transparent">
<v-expansion-panel-title
class="section-title"
:min-height="36"
data-testid="enface-footprints-section-toggle"
>Segmentation projection</v-expansion-panel-title
>
<v-expansion-panel-text>
<ReasonedAction :reason="segmentationReason ?? undefined" block>
<v-switch
:model-value="settings.showFootprints"
@update:model-value="updateEnabled"
color="secondary"
density="compact"
hide-details
:disabled="!!segmentationReason"
label="Show segment footprints"
aria-label="Show segment footprints"
data-testid="enface-footprints-toggle"
/>
</ReasonedAction>
<ReasonedAction :reason="segmentationReason ?? undefined" block>
<v-select
:model-value="settings.footprintSegmentIds"
@update:model-value="emit('patch', { footprintSegmentIds: $event })"
:items="segments"
label="Projected segments"
aria-label="Projected segments"
data-testid="enface-footprint-segments"
density="compact"
variant="outlined"
multiple
chips
closable-chips
hide-details
class="mb-3"
:disabled="!!segmentationReason"
>
<template #item="{ props: itemProps, item }"
><v-list-item v-bind="itemProps"
><template #prepend
><ColorDot
v-if="item.raw.color"
:color="item.raw.color"
class="mr-2"
aria-hidden="true" /></template></v-list-item
></template>
</v-select>
</ReasonedAction>
<ReasonedAction
:reason="segmentationReason ?? undefined"
tooltip="Shows a segment wherever its voxels intersect the selected depth range. Segments at different depths can overlap in projection."
block
>
<v-select
:model-value="settings.footprintScope"
@update:model-value="emit('patch', { footprintScope: $event })"
:items="scopes"
label="Footprint depth"
aria-label="Footprint depth"
data-testid="enface-footprint-scope"
density="compact"
variant="outlined"
hide-details
:disabled="!!segmentationReason"
/>
</ReasonedAction>
</v-expansion-panel-text>
</v-expansion-panel>
</template>

<style scoped>
.section-title {
padding: 0 4px;
font-size: 0.8125rem;
}
:deep(.v-switch .v-label) {
font-size: 0.875rem;
}
</style>
65 changes: 65 additions & 0 deletions src/enface/EnFaceSegmentFootprint.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, toRefs, watchEffect } from 'vue';
import type { SlabSpecification } from '@/src/core/slab/types';
import type { SlabGeometry } from '@/src/core/slab/geometry';
import {
useOccupiedDepth,
type OccupiedDepthMeasurer,
} from '@/src/segmentation/measurements/useOccupiedDepth';
import {
useOccupiedDepthInput,
type OccupiedDepthProjection,
} from './useOccupiedDepthInput';
import { useThinRegionAppearance } from './useThinRegionAppearance';
import VtkEnFaceLabelmapRepresentation from './VtkEnFaceLabelmapRepresentation.vue';

const props = defineProps<{
viewId: string;
segmentId: string;
source: OccupiedDepthProjection['source'];
slab: SlabSpecification;
geometry: SlabGeometry | null;
blockedReason: string | null;
createMeasurer: () => OccupiedDepthMeasurer;
}>();
const emit = defineEmits<{ status: [message: string | null] }>();
const { viewId, segmentId, slab, blockedReason } = toRefs(props);
const input = useOccupiedDepthInput(
viewId,
{ source: props.source, slab, blockedReason },
{ segmentId, enabled: true, calibrated: false }
);
const appearance = useThinRegionAppearance(
computed(() => input.selectedMask.value?.id),
'show its footprint'
);
const { accepted, computing, error } = useOccupiedDepth(
input.request,
props.createMeasurer
);
const message = computed(
() =>
input.thicknessReason.value ||
appearance.value?.reason ||
error.value ||
(computing.value && !accepted.value
? 'Projecting segment footprints…'
: null)
);
watchEffect(() => emit('status', message.value));
const visibleAppearance = computed(() =>
!input.thicknessReason.value && !appearance.value?.reason && !error.value
? appearance.value
: null
);
</script>

<template>
<VtkEnFaceLabelmapRepresentation
:geometry="geometry"
:measurement="accepted"
:threshold="null"
:microns-per-voxel="0"
:appearance="visibleAppearance"
/>
</template>
79 changes: 79 additions & 0 deletions src/enface/EnFaceSegmentationProjection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, onScopeDispose, reactive, watchEffect } from 'vue';
import type { SlabSpecification } from '@/src/core/slab/types';
import type { SlabGeometry } from '@/src/core/slab/geometry';
import { useSegmentationStore } from '@/src/segmentation/store';
import { createWorkerMeasurer } from '@/src/segmentation/measurements/useOccupiedDepth';
import type { OccupiedDepthProjection } from './useOccupiedDepthInput';
import { useEnFaceViewStore } from './store';
import EnFaceSegmentFootprint from './EnFaceSegmentFootprint.vue';
import EnFaceMeasurementStatus from './EnFaceMeasurementStatus.vue';

const props = defineProps<{
viewId: string;
source: OccupiedDepthProjection['source'];
slab: SlabSpecification;
geometry: SlabGeometry | null;
blockedReason: string | null;
}>();
const store = useEnFaceViewStore();
const segmentation = useSegmentationStore();
const settings = computed(() =>
store.settingsFor(props.viewId, props.source.currentImageID.value)
);
const masks = computed(() => {
if (!settings.value.showFootprints || props.blockedReason) return [];
return [...new Set(settings.value.footprintSegmentIds)].flatMap((id) => {
const mask = segmentation.maskFor(props.source.currentImageID.value, id);
return mask && segmentation.labelmapDescriptorByMask[mask.id]?.visible
? [mask]
: [];
});
});
const selectedSlab = computed(() =>
settings.value.footprintScope === 'volume'
? {
...props.slab,
first: 0,
last:
(props.source.currentImageData.value?.getDimensions()[
props.slab.axis
] ?? 1) - 1,
}
: props.slab
);
const measurer = createWorkerMeasurer();
const borrowMeasurer = () => ({ measure: measurer.measure, release() {} });
onScopeDispose(measurer.release);
const statuses = reactive<Record<string, string | null>>({});
watchEffect(() => {
const ids = masks.value.map((mask) => mask.id);
measurer.retain(ids);
if (!ids.length) measurer.release();
Object.keys(statuses).forEach((id) => {
if (!ids.includes(id)) delete statuses[id];
});
});
const message = computed(
() =>
[
...new Set(masks.value.map((mask) => statuses[mask.id]).filter(Boolean)),
].join(' ') || null
);
</script>

<template>
<EnFaceSegmentFootprint
v-for="mask in masks"
:key="mask.id"
:view-id="viewId"
:segment-id="mask.segmentId"
:source="source"
:slab="selectedSlab"
:geometry="geometry"
:blocked-reason="blockedReason"
:create-measurer="borrowMeasurer"
@status="statuses[mask.id] = $event"
/>
<EnFaceMeasurementStatus :message="message" />
</template>
12 changes: 12 additions & 0 deletions src/enface/EnFaceViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -158,6 +163,13 @@ watch(
:plane-normal="geometry.planeNormal"
/>
<VtkEnFaceLabelmapRepresentation v-bind="thinRegions" />
<EnFaceSegmentationProjection
:view-id="viewId"
:source="source"
:slab="specification"
:geometry="geometry"
:blocked-reason="stateMessage"
/>
</VtkSliceView>
<EnFaceStatus :message="stateMessage" :ready="projectionReady" />
<EnFaceMeasurementStatus :message="measurementMessage" />
Expand Down
2 changes: 1 addition & 1 deletion src/enface/VtkEnFaceLabelmapRepresentation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down
23 changes: 12 additions & 11 deletions src/enface/__tests__/controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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()] },
});
Expand Down Expand Up @@ -471,6 +471,7 @@ function mountImageControls(spacing = 0.01) {
input.source.currentImageID.value
),
thickness: thickness.controls.value,
footprints: thickness.controls.value,
});
},
});
Expand Down
4 changes: 4 additions & 0 deletions src/enface/__tests__/inputCalibration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/enface/__tests__/restoreSelection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
},
}),
Expand Down
Loading
Loading