From cbe326639cf8a06d21f857ec4f5577898081df32 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 20 Sep 2026 13:16:56 +0000 Subject: [PATCH 01/11] feat(segmentation): replace segment groups with the segmentation module One image-scoped segmentation replaces segment groups. This adds the store that owns mask identity, attachment and lifecycle; labelmap import, restore and interchange; the paint, polygon and process editing paths with their algorithms and workers; the slice representation; manifest 7.0.0 serialization with migration of legacy state; the annotation tools and stores retargeted at segment identity; processing inputs bound directly to segmentations; the flat segment list with sidebar controls, shortcuts and eyedropper; and the unit and end-to-end coverage. It deletes the segment group store, its view config and the old controls. Suggested reading order: src/segmentation/store.ts, segments.ts, io/, editing/ and rendering/; src/io/state-file and src/io/import; src/store/tools, src/core/tools and src/components/tools; src/processing and backend-contract; src/segmentation/components and composables with src/components; then tests. --- backend-contract/generated/openapi.json | 2 +- .../generated/task-spec.schema.json | 2 +- backend-contract/processing/task-spec.ts | 2 +- docs/configuration_file.md | 130 +- docs/loading_data.md | 2 +- docs/server.md | 2 +- docs/state_files.md | 69 +- docs/toolbar.md | 83 +- src/__tests__/segmentGroupRemoval.spec.ts | 67 + src/__tests__/sourceAudit.ts | 59 + src/actions/loadUserFiles.ts | 24 +- src/assets/eyedropper-cursor.svg | 3 + src/components/AnnotationsModule.vue | 95 +- src/components/CineViewer.vue | 5 +- src/components/ColorDot.vue | 24 - src/components/ControlsStripTools.vue | 33 +- src/components/EditableChipList.vue | 85 - src/components/EditableItemList.vue | 317 ++++ .../GaussianSmoothParameterControls.vue | 4 +- src/components/ImageDataBrowser.vue | 20 +- src/components/LabelControls.vue | 158 -- src/components/LabelEditor.vue | 84 +- src/components/MeasurementRulerDetails.vue | 26 - src/components/MeasurementToolDetails.vue | 17 - src/components/MeasurementsToolList.vue | 416 ++--- src/components/MenuControlButton.vue | 14 +- src/components/ModulePanel.vue | 1 + src/components/PatientStudyVolumeBrowser.vue | 23 +- src/components/PolygonControls.vue | 10 - src/components/ProcessControls.vue | 4 +- src/components/ProcessTypeSelector.vue | 2 +- src/components/RectangleControls.vue | 10 - src/components/RulerControls.vue | 10 - src/components/SaveSegmentGroupDialog.vue | 114 -- src/components/SegmentEditor.vue | 66 - src/components/SegmentGroupControls.vue | 423 ----- src/components/SegmentGroupOpacity.vue | 101 -- src/components/SegmentList.vue | 284 ---- src/components/SliceViewer.vue | 28 +- src/components/ToolControls.vue | 91 +- src/components/ToolLabelEditor.vue | 67 - src/components/__tests__/LabelEditor.spec.ts | 58 + .../__tests__/MeasurementDetails.spec.ts | 77 - .../PatientStudyVolumeBrowser.spec.ts | 80 + src/components/processes.ts | 10 +- src/components/styles/annotation-panels.css | 21 + .../tools/AnnotationContextMenu.vue | 7 +- src/components/tools/AnnotationInfo.vue | 3 +- src/components/tools/ScalarProbe.vue | 46 +- .../tools/__tests__/ScalarProbe.spec.ts | 150 ++ src/components/tools/paint/PaintWidget2D.vue | 88 +- src/components/tools/polygon/PolygonTool.vue | 209 +-- .../tools/polygon/PolygonWidget2D.vue | 14 +- .../tools/rectangle/RectangleTool.vue | 10 +- .../tools/rectangle/RectangleWidget2D.vue | 14 +- src/components/tools/ruler/RulerTool.vue | 6 +- src/components/tools/ruler/RulerWidget2D.vue | 6 +- .../VtkSegmentationSliceRepresentation.vue | 211 --- .../__tests__/labelShortcuts.spec.ts | 104 ++ .../__tests__/useKeyboardShortcuts.spec.ts | 2 +- src/composables/actions.ts | 57 +- src/composables/annotationTool.ts | 27 +- src/composables/useGlobalLayerColorConfig.ts | 33 - src/composables/useKeyboardShortcuts.ts | 10 +- src/composables/useMultipleToolSelection.ts | 65 - src/config.ts | 33 +- src/constants.ts | 27 + .../__tests__/snappedCenter.spec.ts | 61 + src/core/annotations/locator.ts | 78 +- src/core/manifestRefs.ts | 4 +- src/core/streaming/dicomChunkImage.ts | 4 +- src/core/tools/__tests__/paint.spec.ts | 2 +- src/core/tools/paint/fillHoles.ts | 182 --- src/core/tools/paint/fillHoles.worker.ts | 14 - src/core/tools/paint/gaussianSmooth.worker.ts | 338 ---- src/core/tools/paint/index.ts | 151 +- src/core/vtk/useVtkView.ts | 10 +- src/io/__tests__/segNrrdMetadata.spec.ts | 57 +- src/io/dicom.ts | 6 +- src/io/import/__tests__/configIo.spec.ts | 37 + src/io/import/__tests__/configJson.spec.ts | 248 ++- .../__tests__/configRecognition.spec.ts | 14 +- .../import/__tests__/postStateLabels.spec.ts | 114 ++ .../processingConfigInjection.spec.ts | 6 +- .../__tests__/repeatedSegmentIds.spec.ts | 37 + .../__tests__/restoreStateFileLeaves.spec.ts | 46 +- .../__tests__/restoreStateIdCollision.spec.ts | 111 +- src/io/import/configIo.ts | 26 + src/io/import/configJson.ts | 163 +- src/io/import/dataSource.ts | 4 +- src/io/import/importDataSources.ts | 6 +- src/io/import/labelmapImports.ts | 112 ++ src/io/import/processors/handleConfig.ts | 15 +- src/io/import/processors/restoreStateFile.ts | 184 +-- src/io/readWriteImage.ts | 6 +- src/io/segNrrdMetadata.ts | 23 +- .../__tests__/annotationToolSource.spec.ts | 8 +- .../__tests__/boundedMaskRoundTrip.spec.ts | 631 ++++++++ .../__tests__/droppedMaskReport.spec.ts | 124 ++ .../__tests__/legacyManifestMigration.spec.ts | 40 + .../__tests__/maskArchivePath.spec.ts | 25 + .../__tests__/maskIoConcurrency.spec.ts | 222 +++ .../__tests__/migrate640To700.spec.ts | 1299 +++++++++++++++ .../__tests__/segmentGroupArchivePath.spec.ts | 25 - .../__tests__/segmentGroupSource.spec.ts | 99 -- .../segmentationArtifactSource.spec.ts | 172 ++ .../segmentationDisplayRoundTrip.spec.ts | 171 ++ .../__tests__/segmentationRoundTrip.spec.ts | 159 ++ .../__tests__/serializeResilience.spec.ts | 185 ++- .../__tests__/sessionSavePreview.spec.ts | 41 + src/io/state-file/dataSourceDisplayName.ts | 53 + ...GroupArchivePath.ts => maskArchivePath.ts} | 18 +- src/io/state-file/migrations.ts | 446 ++++- src/io/state-file/schema.ts | 140 +- src/io/state-file/serialize.ts | 100 +- .../applyResults.annotations.spec.ts | 303 +++- .../__tests__/applyResults.segments.spec.ts | 171 ++ src/processing/__tests__/applyResults.spec.ts | 199 +-- src/processing/applyResults.ts | 199 ++- src/processing/components/JobsModule.vue | 89 +- src/processing/components/TaskForm.vue | 33 +- .../components/__tests__/JobsModule.spec.ts | 254 ++- .../components/widgets/FileWidget.vue | 23 +- .../widgets/__tests__/FileWidget.spec.ts | 26 +- src/processing/composables/useInputStaging.ts | 120 +- .../engine/__tests__/annotationsWire.spec.ts | 15 +- .../engine/__tests__/mintInput.spec.ts | 1 - .../engine/__tests__/mintLabelmap.spec.ts | 158 +- .../__tests__/sourceRefBindingContext.ts | 6 +- .../engine/__tests__/sourceRefs.spec.ts | 274 +--- src/processing/engine/annotationsWire.ts | 6 +- src/processing/engine/jobDisplay.ts | 11 +- src/processing/engine/mintInput.ts | 17 +- src/processing/engine/mintLabelmap.ts | 82 +- src/processing/engine/sourceRefs.ts | 83 +- src/processing/store.ts | 2 +- src/processing/types.ts | 2 +- src/segmentation/README.md | 57 + .../__tests__/allSegmentsProcess.spec.ts | 324 ++++ .../__tests__/annotationToolSegments.spec.ts | 334 ++++ .../__tests__/artifactParentRestore.spec.ts | 526 ++++++ .../__tests__/boundedPaintStroke.spec.ts | 332 ++++ .../__tests__/boundedSegmentMasks.spec.ts | 329 ++++ .../__tests__/configSegments.spec.ts | 361 +++++ .../convertParentRemovedMidImport.spec.ts | 135 ++ .../descriptorlessActiveSegment.spec.ts | 211 +++ src/segmentation/__tests__/fillHoles.spec.ts | 391 +++++ .../__tests__/findMaskVoxels.spec.ts | 231 +++ .../lockedEditCreatesNothing.spec.ts | 109 ++ .../__tests__/maskAndNameLookups.spec.ts | 228 +++ .../__tests__/paintProcess.spec.ts | 443 +++++ .../__tests__/paintProcessStorage.spec.ts | 524 ++++++ .../__tests__/paintTarget.spec.ts | 217 +++ .../__tests__/processGrowth.spec.ts | 305 ++++ .../__tests__/processOverlap.spec.ts | 214 +++ .../__tests__/restoreSegmentLifetime.spec.ts | 165 ++ .../segmentArtifactBookkeeping.spec.ts | 193 +++ .../__tests__/segmentDisplayState.spec.ts | 200 +++ .../segmentGroupDescriptorlessParity.spec.ts | 223 +-- .../segmentGroupRestoreResilience.spec.ts | 227 ++- .../__tests__/segmentIdentityKey.spec.ts | 156 ++ .../__tests__/segmentImportPath.spec.ts | 368 +++++ .../__tests__/segmentLayers.spec.ts | 321 ++++ .../__tests__/segmentMaskComposite.spec.ts | 477 ++++++ .../__tests__/segmentMaskFixtures.ts | 405 +++++ .../__tests__/segmentOverwrite.spec.ts | 214 +++ .../__tests__/segmentRegistry.spec.ts | 327 ++++ .../__tests__/segmentVoxels.spec.ts | 274 ++++ .../__tests__/segmentations.spec.ts | 864 ++++++++++ .../__tests__/sharedTypeAcrossImages.spec.ts | 188 +++ .../components/FillHolesParameterControls.vue | 18 +- .../components/PaintControls.vue | 138 +- .../components/ProcessWorkflow.vue | 15 +- .../components/SaveSegmentationDialog.vue | 163 ++ .../components/SegmentAssignmentList.vue | 41 + src/segmentation/components/SegmentEditor.vue | 101 ++ src/segmentation/components/SegmentList.vue | 579 +++++++ .../components/SegmentListActions.vue | 101 ++ .../__tests__/ProcessWorkflow.spec.ts | 127 ++ .../__tests__/SegmentEditor.spec.ts | 92 ++ .../components/__tests__/SegmentList.spec.ts | 1432 +++++++++++++++++ .../__tests__/segmentPanelSurface.spec.ts | 328 ++++ src/segmentation/composables/deleteSegment.ts | 54 + .../composables/useMaskRevision.ts | 35 + .../composables/usePaintInteractionMode.ts | 14 + .../composables/useSegmentEditing.ts | 100 ++ .../composables/useSegmentRevealPulse.ts | 30 + .../composables/useSegmentShapes.ts | 93 ++ .../editing/__tests__/processWorker.spec.ts | 83 + .../__tests__/rasterizePolygon.spec.ts | 382 +++++ .../editing/__tests__/rasterizeTarget.spec.ts | 215 +++ .../__tests__/rasterizeWithProcess.spec.ts | 113 ++ .../editing/__tests__/silentWorker.ts | 59 + .../algorithms}/__tests__/fillHoles.spec.ts | 157 +- .../__tests__/gaussianSmooth.spec.ts | 20 +- .../__tests__/gaussianSmoothGolden.spec.ts | 2 +- .../algorithms/gaussianSmooth.worker.ts | 13 +- src/segmentation/editing/fillBetween.ts | 51 + src/segmentation/editing/fillHoles.ts | 130 ++ src/segmentation/editing/gaussianSmooth.ts | 72 + src/segmentation/editing/paintProcess.ts | 637 ++++++++ src/segmentation/editing/processWorker.ts | 93 ++ src/segmentation/editing/rasterizePolygon.ts | 205 +++ src/segmentation/io/composition.ts | 101 ++ src/segmentation/io/import.ts | 346 ++++ src/segmentation/io/maskFileNaming.ts | 4 +- src/segmentation/io/restore.ts | 150 ++ src/segmentation/io/stateFile.ts | 610 +++++++ src/segmentation/masks/overlap.ts | 113 +- src/segmentation/model.ts | 13 +- .../VtkSegmentationSliceRepresentation.vue | 263 +++ src/segmentation/segmentRegistry.ts | 138 +- src/segmentation/segments.ts | 45 + src/segmentation/store.ts | 677 ++++++++ src/shims-vtk.d.ts | 2 + .../annotationToolImageDelete.spec.ts | 10 +- .../__tests__/datasetRemoveCascade.spec.ts | 125 +- src/store/__tests__/fillHoles.spec.ts | 171 -- src/store/__tests__/image-stats.spec.ts | 165 ++ .../legacyManifestSegmentGroups.spec.ts | 126 +- src/store/__tests__/paintProcess.spec.ts | 164 -- src/store/__tests__/rulers.spec.ts | 146 +- src/store/__tests__/views.spec.ts | 2 +- src/store/datasets.ts | 2 +- src/store/image-stats.ts | 19 +- src/store/load-data.ts | 4 +- src/store/segmentGroups.ts | 802 --------- .../restoreSkipsUnloadedImage.spec.ts | 107 ++ src/store/tools/fillBetween.ts | 26 - src/store/tools/fillHoles.ts | 134 -- src/store/tools/gaussianSmooth.ts | 78 - src/store/tools/index.ts | 22 +- src/store/tools/paint.ts | 359 ++--- src/store/tools/paintProcess.ts | 262 --- src/store/tools/polygons.ts | 19 +- src/store/tools/rectangles.ts | 22 +- src/store/tools/rulers.ts | 15 +- src/store/tools/types.ts | 8 +- src/store/tools/useAnnotationTool.ts | 184 ++- src/store/tools/useLabels.ts | 129 -- src/store/view-configs/layers.ts | 2 +- src/store/view-configs/segmentGroups.ts | 130 -- src/store/view-configs/types.ts | 5 - src/types/annotation-tool.ts | 6 +- src/types/segment.ts | 9 - .../__tests__/allocateImageFromChunks.spec.ts | 2 +- src/utils/bugReport.ts | 13 +- src/utils/dataSelection.ts | 11 + src/vtk/LabelMap/index.d.ts | 24 +- src/vtk/LabelMap/index.js | 43 +- src/vtk/PaintWidget/behavior.ts | 44 +- src/vtk/PaintWidget/index.d.ts | 1 + .../RulerWidget/__tests__/behavior.spec.ts | 97 ++ src/vtk/RulerWidget/behavior.ts | 17 +- ...ection_labelmap_paint_coronal-chrome-1.png | Bin 165426 -> 165316 bytes ..._tool_axial_view_after_stroke-chrome-1.png | Bin 134612 -> 134571 bytes tests/fixtures/label-outline/index.html | 6 + tests/fixtures/label-outline/scene.ts | 141 ++ tests/fixtures/label-outline/server.mjs | 26 + tests/pageobjects/volview.page.ts | 57 +- tests/specs/annotation-mints-segment.e2e.ts | 66 + tests/specs/annotationTestUtils.ts | 50 + tests/specs/annotations-sidebar.e2e.ts | 379 +++++ tests/specs/cine-ruler-session.e2e.ts | 12 +- tests/specs/configTestUtils.ts | 9 +- tests/specs/delete-selected-annotation.e2e.ts | 15 +- .../specs/different-direction-labelmap.e2e.ts | 30 +- tests/specs/label-outline.e2e.ts | 87 + tests/specs/labelmap-import-roundtrip.e2e.ts | 168 ++ .../specs/multiple-segmentation-import.e2e.ts | 122 ++ tests/specs/paint-eyedropper.e2e.ts | 141 ++ tests/specs/paint-fill-holes.e2e.ts | 50 +- tests/specs/paint-tool-rendering.e2e.ts | 34 + tests/specs/polygon-rasterize-segment.e2e.ts | 240 +++ tests/specs/reveal-slice.e2e.ts | 35 +- tests/specs/seg-nrrd-export.e2e.ts | 20 +- .../segment-controls-accessibility.e2e.ts | 127 ++ tests/specs/segment-group-download.e2e.ts | 67 +- tests/specs/segment-group-list-scroll.e2e.ts | 26 +- tests/specs/segment-identity-stability.e2e.ts | 106 ++ tests/specs/segment-overlap.e2e.ts | 175 ++ tests/specs/segment-preview-edits.e2e.ts | 257 +++ tests/specs/segment-shared-type.e2e.ts | 98 ++ tests/specs/segment-shortcuts.e2e.ts | 138 ++ tests/specs/segment-tooltips.e2e.ts | 80 + tests/specs/segmentation-extension.e2e.ts | 90 ++ tests/specs/segmentationTestUtils.ts | 170 ++ tests/specs/select-annotation-at-press.e2e.ts | 150 +- tests/specs/session-large-uri-base.e2e.ts | 58 +- tests/specs/session-state-lifecycle.e2e.ts | 167 +- .../sparse-manifest-prostate-rectangle.e2e.ts | 12 +- tests/specs/sparse-manifest.e2e.ts | 41 +- tests/specs/ultrasound-spacing.e2e.ts | 10 +- tests/specs/utils.ts | 21 + 294 files changed, 29463 insertions(+), 7987 deletions(-) create mode 100644 src/__tests__/segmentGroupRemoval.spec.ts create mode 100644 src/__tests__/sourceAudit.ts create mode 100644 src/assets/eyedropper-cursor.svg delete mode 100644 src/components/ColorDot.vue delete mode 100644 src/components/EditableChipList.vue create mode 100644 src/components/EditableItemList.vue delete mode 100644 src/components/LabelControls.vue delete mode 100644 src/components/MeasurementRulerDetails.vue delete mode 100644 src/components/MeasurementToolDetails.vue delete mode 100644 src/components/PolygonControls.vue delete mode 100644 src/components/RectangleControls.vue delete mode 100644 src/components/RulerControls.vue delete mode 100644 src/components/SaveSegmentGroupDialog.vue delete mode 100644 src/components/SegmentEditor.vue delete mode 100644 src/components/SegmentGroupControls.vue delete mode 100644 src/components/SegmentGroupOpacity.vue delete mode 100644 src/components/SegmentList.vue delete mode 100644 src/components/ToolLabelEditor.vue create mode 100644 src/components/__tests__/LabelEditor.spec.ts delete mode 100644 src/components/__tests__/MeasurementDetails.spec.ts create mode 100644 src/components/__tests__/PatientStudyVolumeBrowser.spec.ts create mode 100644 src/components/styles/annotation-panels.css create mode 100644 src/components/tools/__tests__/ScalarProbe.spec.ts delete mode 100644 src/components/vtk/VtkSegmentationSliceRepresentation.vue create mode 100644 src/composables/__tests__/labelShortcuts.spec.ts delete mode 100644 src/composables/useGlobalLayerColorConfig.ts delete mode 100644 src/composables/useMultipleToolSelection.ts create mode 100644 src/core/annotations/__tests__/snappedCenter.spec.ts delete mode 100644 src/core/tools/paint/fillHoles.ts delete mode 100644 src/core/tools/paint/fillHoles.worker.ts delete mode 100644 src/core/tools/paint/gaussianSmooth.worker.ts create mode 100644 src/io/import/__tests__/configIo.spec.ts create mode 100644 src/io/import/__tests__/postStateLabels.spec.ts create mode 100644 src/io/import/__tests__/repeatedSegmentIds.spec.ts create mode 100644 src/io/import/configIo.ts create mode 100644 src/io/import/labelmapImports.ts create mode 100644 src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts create mode 100644 src/io/state-file/__tests__/droppedMaskReport.spec.ts create mode 100644 src/io/state-file/__tests__/legacyManifestMigration.spec.ts create mode 100644 src/io/state-file/__tests__/maskArchivePath.spec.ts create mode 100644 src/io/state-file/__tests__/maskIoConcurrency.spec.ts create mode 100644 src/io/state-file/__tests__/migrate640To700.spec.ts delete mode 100644 src/io/state-file/__tests__/segmentGroupArchivePath.spec.ts delete mode 100644 src/io/state-file/__tests__/segmentGroupSource.spec.ts create mode 100644 src/io/state-file/__tests__/segmentationArtifactSource.spec.ts create mode 100644 src/io/state-file/__tests__/segmentationDisplayRoundTrip.spec.ts create mode 100644 src/io/state-file/__tests__/segmentationRoundTrip.spec.ts create mode 100644 src/io/state-file/__tests__/sessionSavePreview.spec.ts create mode 100644 src/io/state-file/dataSourceDisplayName.ts rename src/io/state-file/{segmentGroupArchivePath.ts => maskArchivePath.ts} (50%) create mode 100644 src/processing/__tests__/applyResults.segments.spec.ts create mode 100644 src/segmentation/README.md create mode 100644 src/segmentation/__tests__/allSegmentsProcess.spec.ts create mode 100644 src/segmentation/__tests__/annotationToolSegments.spec.ts create mode 100644 src/segmentation/__tests__/artifactParentRestore.spec.ts create mode 100644 src/segmentation/__tests__/boundedPaintStroke.spec.ts create mode 100644 src/segmentation/__tests__/boundedSegmentMasks.spec.ts create mode 100644 src/segmentation/__tests__/configSegments.spec.ts create mode 100644 src/segmentation/__tests__/convertParentRemovedMidImport.spec.ts create mode 100644 src/segmentation/__tests__/descriptorlessActiveSegment.spec.ts create mode 100644 src/segmentation/__tests__/fillHoles.spec.ts create mode 100644 src/segmentation/__tests__/findMaskVoxels.spec.ts create mode 100644 src/segmentation/__tests__/lockedEditCreatesNothing.spec.ts create mode 100644 src/segmentation/__tests__/maskAndNameLookups.spec.ts create mode 100644 src/segmentation/__tests__/paintProcess.spec.ts create mode 100644 src/segmentation/__tests__/paintProcessStorage.spec.ts create mode 100644 src/segmentation/__tests__/paintTarget.spec.ts create mode 100644 src/segmentation/__tests__/processGrowth.spec.ts create mode 100644 src/segmentation/__tests__/processOverlap.spec.ts create mode 100644 src/segmentation/__tests__/restoreSegmentLifetime.spec.ts create mode 100644 src/segmentation/__tests__/segmentArtifactBookkeeping.spec.ts create mode 100644 src/segmentation/__tests__/segmentDisplayState.spec.ts rename src/{store => segmentation}/__tests__/segmentGroupDescriptorlessParity.spec.ts (54%) rename src/{store => segmentation}/__tests__/segmentGroupRestoreResilience.spec.ts (63%) create mode 100644 src/segmentation/__tests__/segmentIdentityKey.spec.ts create mode 100644 src/segmentation/__tests__/segmentImportPath.spec.ts create mode 100644 src/segmentation/__tests__/segmentLayers.spec.ts create mode 100644 src/segmentation/__tests__/segmentMaskComposite.spec.ts create mode 100644 src/segmentation/__tests__/segmentMaskFixtures.ts create mode 100644 src/segmentation/__tests__/segmentOverwrite.spec.ts create mode 100644 src/segmentation/__tests__/segmentRegistry.spec.ts create mode 100644 src/segmentation/__tests__/segmentVoxels.spec.ts create mode 100644 src/segmentation/__tests__/segmentations.spec.ts create mode 100644 src/segmentation/__tests__/sharedTypeAcrossImages.spec.ts rename src/{ => segmentation}/components/FillHolesParameterControls.vue (77%) rename src/{ => segmentation}/components/PaintControls.vue (66%) rename src/{ => segmentation}/components/ProcessWorkflow.vue (81%) create mode 100644 src/segmentation/components/SaveSegmentationDialog.vue create mode 100644 src/segmentation/components/SegmentAssignmentList.vue create mode 100644 src/segmentation/components/SegmentEditor.vue create mode 100644 src/segmentation/components/SegmentList.vue create mode 100644 src/segmentation/components/SegmentListActions.vue create mode 100644 src/segmentation/components/__tests__/ProcessWorkflow.spec.ts create mode 100644 src/segmentation/components/__tests__/SegmentEditor.spec.ts create mode 100644 src/segmentation/components/__tests__/SegmentList.spec.ts create mode 100644 src/segmentation/components/__tests__/segmentPanelSurface.spec.ts create mode 100644 src/segmentation/composables/deleteSegment.ts create mode 100644 src/segmentation/composables/useMaskRevision.ts create mode 100644 src/segmentation/composables/usePaintInteractionMode.ts create mode 100644 src/segmentation/composables/useSegmentEditing.ts create mode 100644 src/segmentation/composables/useSegmentRevealPulse.ts create mode 100644 src/segmentation/composables/useSegmentShapes.ts create mode 100644 src/segmentation/editing/__tests__/processWorker.spec.ts create mode 100644 src/segmentation/editing/__tests__/rasterizePolygon.spec.ts create mode 100644 src/segmentation/editing/__tests__/rasterizeTarget.spec.ts create mode 100644 src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts create mode 100644 src/segmentation/editing/__tests__/silentWorker.ts rename src/{core/tools/paint => segmentation/editing/algorithms}/__tests__/fillHoles.spec.ts (50%) create mode 100644 src/segmentation/editing/fillBetween.ts create mode 100644 src/segmentation/editing/fillHoles.ts create mode 100644 src/segmentation/editing/gaussianSmooth.ts create mode 100644 src/segmentation/editing/paintProcess.ts create mode 100644 src/segmentation/editing/processWorker.ts create mode 100644 src/segmentation/editing/rasterizePolygon.ts create mode 100644 src/segmentation/io/composition.ts create mode 100644 src/segmentation/io/import.ts create mode 100644 src/segmentation/io/restore.ts create mode 100644 src/segmentation/io/stateFile.ts create mode 100644 src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue create mode 100644 src/segmentation/segments.ts create mode 100644 src/segmentation/store.ts delete mode 100644 src/store/__tests__/fillHoles.spec.ts create mode 100644 src/store/__tests__/image-stats.spec.ts delete mode 100644 src/store/__tests__/paintProcess.spec.ts delete mode 100644 src/store/segmentGroups.ts create mode 100644 src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts delete mode 100644 src/store/tools/fillBetween.ts delete mode 100644 src/store/tools/fillHoles.ts delete mode 100644 src/store/tools/gaussianSmooth.ts delete mode 100644 src/store/tools/paintProcess.ts delete mode 100644 src/store/tools/useLabels.ts delete mode 100644 src/store/view-configs/segmentGroups.ts delete mode 100644 src/types/segment.ts create mode 100644 src/vtk/RulerWidget/__tests__/behavior.spec.ts create mode 100644 tests/fixtures/label-outline/index.html create mode 100644 tests/fixtures/label-outline/scene.ts create mode 100644 tests/fixtures/label-outline/server.mjs create mode 100644 tests/specs/annotation-mints-segment.e2e.ts create mode 100644 tests/specs/annotations-sidebar.e2e.ts create mode 100644 tests/specs/label-outline.e2e.ts create mode 100644 tests/specs/labelmap-import-roundtrip.e2e.ts create mode 100644 tests/specs/multiple-segmentation-import.e2e.ts create mode 100644 tests/specs/paint-eyedropper.e2e.ts create mode 100644 tests/specs/polygon-rasterize-segment.e2e.ts create mode 100644 tests/specs/segment-controls-accessibility.e2e.ts create mode 100644 tests/specs/segment-identity-stability.e2e.ts create mode 100644 tests/specs/segment-overlap.e2e.ts create mode 100644 tests/specs/segment-preview-edits.e2e.ts create mode 100644 tests/specs/segment-shared-type.e2e.ts create mode 100644 tests/specs/segment-shortcuts.e2e.ts create mode 100644 tests/specs/segment-tooltips.e2e.ts create mode 100644 tests/specs/segmentation-extension.e2e.ts create mode 100644 tests/specs/segmentationTestUtils.ts diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index c9b2ee04e..8e8938a76 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -717,7 +717,7 @@ } }, "multiple": { - "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.", + "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. The client currently stages the whole segmentation of the active image as one flattened labelmap file for either setting; the flag does not select additional segmentations.", "type": "boolean" } }, diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index 9e08b404f..b7a67bc8a 100644 --- a/backend-contract/generated/task-spec.schema.json +++ b/backend-contract/generated/task-spec.schema.json @@ -310,7 +310,7 @@ } }, "multiple": { - "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.", + "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. The client currently stages the whole segmentation of the active image as one flattened labelmap file for either setting; the flag does not select additional segmentations.", "type": "boolean" } }, diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts index 0097d72bc..c69f12653 100644 --- a/backend-contract/processing/task-spec.ts +++ b/backend-contract/processing/task-spec.ts @@ -136,7 +136,7 @@ const sourceRefParam = z.object({ .boolean() .optional() .describe( - 'When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.' + 'When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. The client currently stages the whole segmentation of the active image as one flattened labelmap file for either setting; the flag does not select additional segmentations.' ), }); diff --git a/docs/configuration_file.md b/docs/configuration_file.md index bff0f0fd6..bc6c968da 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -4,7 +4,7 @@ By loading a JSON file, you can set VolView's configuration: - View layouts (grid size, view types, or hierarchical layouts) - Disabled view types -- Labels for tools +- Segments - Visibility of Sample Data section - Keyboard shortcuts @@ -149,26 +149,69 @@ 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"` -## Labels for tools +## Segments -Each tool type (Rectangle, Polygon, etc.) can have tool specific labels. To share labels -across tools, define the `defaultLabels` key and don't provide labels for a tool that -should use the default labels. +Paint, rectangles, polygons and rulers share one registry of segments, configured under +`segments`. Each entry is keyed by name, and every appearance field is optional: an +omitted one means the app default for a new segment. For an existing session segment, +omitted fields keep the appearance it had before configuration. Replacing a config entry +removes its previous appearance overrides, including color, while keeping the segment id, +visibility and lock state. + +```json +{ + "segments": { + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3, "fillOpacity": 0.5 } + } +} +``` + +Fields: `color`, `fillOpacity`, `outlineOpacity`, `strokeWidth`. + +Omitting the key leaves the registry alone. An empty record (`{}`) or `null` clears what an +earlier config contributed, keeping any segment your content still references with its +last configured appearance. A configured +segment keeps its id across config changes, so renaming or recoloring one never detaches +the masks and shapes that reference it. + +### Pre-7.0 `labels` + +A pre-7.0 `labels` section still loads. Its `defaultLabels`, `rulerLabels`, +`rectangleLabels` and `polygonLabels` all describe the one registry now, so they read as +`segments` entries. A name that appears in more than one becomes a single segment: the +first record to declare it sets its appearance, reading `rulerLabels`, `rectangleLabels` +and `polygonLabels` in that order and `defaultLabels` last, since it stood in only for the +tools that declared no record of their own. A rectangle label's `fillColor` is dropped, +since fill color is a property of the rectangle rather than of the segment. A config +carrying both `segments` and `labels` has been converted already, so `segments` is read +and `labels` is ignored. + +Converting a config by hand: ```json { "labels": { - "defaultLabels": { - "lesion": { "color": "#ff0000" }, - "tumor": { "color": "green", "strokeWidth": 3 } - } + "defaultLabels": { "lesion": { "color": "#ff0000" } }, + "rulerLabels": { "big": { "color": "#ff0000" } } + } +} +``` + +becomes + +```json +{ + "segments": { + "lesion": { "color": "#ff0000" }, + "big": { "color": "#ff0000" } } } ``` -## Segment Group File Format +## Session Mask File Format -The `segmentGroupSaveFormat` key specifies the file extension of the segment group images +The `segmentGroupSaveFormat` key specifies the file extension of the mask images VolView will include in the volview.zip file. ```json @@ -179,36 +222,49 @@ VolView will include in the volview.zip file. } ``` -Working segment group file formats: +Working mask file formats: hdf5, iwi.cbor, mha, nii, nii.gz, nrrd, vtk -## Automatic Layers and Segment Groups by File Name +## Automatic Layers and Segmentations by File Name When loading multiple files, VolView can automatically associate related images based on file naming patterns. Example: `base.[extension].nrrd` will match `base.nii`. The extension must appear anywhere in the filename after splitting by dots, and the filename must start with the same prefix as the base image (everything before the first dot). Files matching `base.[extension]...` will be associated with a base image named `base.*`. -**Ordering:** When multiple layers/segment groups match a base image, they are sorted alphabetically by filename and added to the stack in that order. To control the stacking order explicitly, you could use numeric prefixes in your filenames. +**Ordering:** When multiple layers/segmentations match a base image, they are sorted alphabetically by filename and added to the stack in that order. To control the stacking order explicitly, you could use numeric prefixes in your filenames. For example, with a base image `patient001.nrrd`: - Layers (sorted alphabetically): `patient001.layer.1.pet.nii`, `patient001.layer.2.ct.mha`, `patient001.layer.3.overlay.vtk` -- Segment groups: `patient001.seg.1.tumor.nii.gz`, `patient001.seg.2.lesion.mha` +- Segmentations: `patient001.seg.1.tumor.nii.gz`, `patient001.seg.2.lesion.mha` Both features default to `''` which disables them. -### Segment Groups +### Configuration migration + +Use `io.segmentationExtension` in new configuration. The old +`io.segmentGroupExtension` key is accepted at ingestion and converted to the +new key. If both keys are present, their values must match; conflicting values +are rejected. An explicit empty string disables automatic matching. + +The value `seg` is the filename marker in `patient.seg.nii.gz`; `nii.gz` is +its encoding extension. This setting preserves the existing filename matching +rule and does not add support for additional segmentation formats. + +Directly loading an old key in VolView also reports a deprecation warning. + +### Segmentations -Use `segmentGroupExtension` to automatically convert matching non-DICOM images to segment groups. -For example, `myFile.seg.nrrd` becomes a segment group for `myFile.nii`. +Use `segmentationExtension` to automatically convert matching non-DICOM images to segmentations. +For example, `myFile.seg.nrrd` becomes a segmentation for `myFile.nii`. Defaults to `''` which disables matching. ```json { "io": { - "segmentGroupExtension": "seg" + "segmentationExtension": "seg" } } ``` @@ -246,11 +302,9 @@ To configure a key for an action, add its action name and the key(s) under the ` ```json { - "labels": { - "defaultLabels": { - "lesion": { "color": "#ff0000" }, - "tumor": { "color": "green", "strokeWidth": 3 } - } + "segments": { + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3 } }, "layouts": { "single-view": { @@ -264,28 +318,10 @@ To configure a key for an action, add its action name and the key(s) under the ` ```json { - "labels": { - "defaultLabels": { - "lesion": { "color": "#ff0000" }, - "tumor": { "color": "green", "strokeWidth": 3 }, - "innocuous": { "color": "white" } - }, - "rulerLabels": { - "big": { "color": "#ff0000" }, - "small": { "color": "white" } - }, - "rectangleLabels": { - "red": { "color": "#ff0000", "fillColor": "transparent" }, - "green": { "color": "green", "fillColor": "transparent" }, - "white-yellow-fill": { - "color": "white", - "fillColor": "#00ff0030" - } - }, - "polygonLabels": { - "poly1": { "color": "#ff0000" }, - "poly2Label": { "color": "green" } - } + "segments": { + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3, "fillOpacity": 0.5 }, + "innocuous": { "color": "white", "outlineOpacity": 0.8 } }, "layouts": { "Volume primary": { @@ -313,7 +349,7 @@ To configure a key for an action, add its action name and the key(s) under the ` }, "io": { "segmentGroupSaveFormat": "nrrd", - "segmentGroupExtension": "seg", + "segmentationExtension": "seg", "layerExtension": "layer" } } diff --git a/docs/loading_data.md b/docs/loading_data.md index b67ed18d3..dce2ce0c8 100644 --- a/docs/loading_data.md +++ b/docs/loading_data.md @@ -70,4 +70,4 @@ To layer images: ## State Files -Load preconfigured scenes with annotations, segment groups, and view settings via [state files](./state_files.md). State files can embed data (`*.volview.zip`) or reference remote data via URIs (`*.volview.json`). +Load preconfigured scenes with annotations, segmentations, and view settings via [state files](./state_files.md). State files can embed data (`*.volview.zip`) or reference remote data via URIs (`*.volview.json`). diff --git a/docs/server.md b/docs/server.md index 297ac4d97..1c887bc0d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -11,7 +11,7 @@ directly. For longer-running work, VolView also ships a Jobs panel that talks to a processing backend over the neutral API defined in the `backend-contract` package: the backend advertises its tasks, VolView builds the submission form from each task specification, and completed outputs load back into the scene as -images, layers, or segment groups. Any service that implements the contract +images, layers, or segmentations. Any service that implements the contract works, since VolView knows only the shared vocabulary and never a backend's native task format. diff --git a/docs/state_files.md b/docs/state_files.md index 0f7f9972a..b34e00faf 100644 --- a/docs/state_files.md +++ b/docs/state_files.md @@ -14,7 +14,74 @@ JSON files that reference remote data via URIs instead of embedding it. Useful f - Sharing annotations without duplicating large datasets - Integrating with external systems (AI pipelines, access control, etc.) -Example manifest: +### Current manifest (version 7.0.0) + +A segmentation owns one image's segment masks; labelmaps encode those masks for +storage or interchange. The top-level `segments` list holds the identities the +masks paint (name, color, visibility), each mask names the segment it carries +voxels for, and `order` lists the masks of that segmentation. + +A mask saved into a zip names its own archive entry with `path`. A sparse +manifest instead points at a whole label volume: `segmentationArtifacts` names +that volume, its `dataSourceId` says where the bytes come from, and each mask +whose `artifactId` points at it is filled from the `sourceValue` it declares. +An artifact is a single-component label volume; one with several components is +skipped on restore. Extents are placeholders until the volume is read. + +```json +{ + "version": "7.0.0", + "dataSources": [ + { "id": 0, "type": "uri", "uri": "https://example.com/scan.zip" }, + { "id": 1, "type": "uri", "uri": "https://example.com/segmentation.nii.gz" } + ], + "segments": [ + { + "id": "segment-tumor", + "name": "Tumor", + "color": [255, 0, 0, 255], + "visible": true, + "locked": false + } + ], + "segmentations": [ + { + "id": "segmentation-0", + "name": "Tumor Segmentation", + "parentImage": "0", + "masks": [ + { + "id": "mask-tumor", + "segmentId": "segment-tumor", + "representations": { + "labelmap": { + "artifactId": "labelmap-1", + "sourceValue": 1, + "extent": [0, -1, 0, -1, 0, -1] + } + } + } + ], + "order": ["mask-tumor"] + } + ], + "segmentationArtifacts": [ + { + "id": "labelmap-1", + "parentImage": "0", + "name": "Tumor Segmentation", + "dataSourceId": 1 + } + ], + "selectedSegment": "segment-tumor" +} +``` + +### Legacy 6.2.0 manifest (the pre-7.0.0 form, still read on import) + +The historical `segmentGroups` field is migrated into the current segmentation +model on load, and the per-tool `labels` records become segments the tools +reference by id. Nothing writes this form any more. ```json { diff --git a/docs/toolbar.md b/docs/toolbar.md index ca1aa6cd7..f3dbd4bde 100644 --- a/docs/toolbar.md +++ b/docs/toolbar.md @@ -14,11 +14,21 @@ Window / Level, Pan, Zoom, or Crosshairs: Select these options to control the fu ## 2D Annotations -The "Annotations" tab lists the drawn, vector based, annotation tools. Each tool in the list has a "scroll to slice" and delete button. +The "Annotations" tab lists segments shared by paint, rectangles, polygons and rulers. +Select a segment in the list or use `q` and `w` to cycle through segments. Use "New +segment" to add one, and its color dot or edit button to change its name and appearance. +The selection applies across all four tools and images. + +Expand a segment to see its shapes on the current image. Each shape has controls to +jump to its slice or cine frame and to delete it. A segment's Reveal button jumps to +its mask or shapes on the current image; it stays disabled when there is no content. ### Paint -When the paint tool is selected, you can paint in any 2D window. Click on the paint tool a second time to bring up a menu of colors and adjust the brush size. Painting automatically switches to the appropriate segment group for the volume being painted. +When the paint tool is selected, you can paint in any supported 2D slice window. +Choose the segment in "Annotations" and use the Paint controls below the segment +list to adjust brush size, switch to erasing, or set an intensity threshold. +Painting adds a mask for the selected segment on the image being painted. ### Rectangle @@ -26,7 +36,7 @@ When the rectangle tool is selected, the left mouse button is used to place and Right click a rectangle control point to delete the rectangle. The "Annotations" tab lists all rectangles and provides jump-to and delete controls. -Rectangle annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New rectangles use the selected segment from "Annotations". ### Polygon @@ -44,70 +54,39 @@ After closing a polygon: - Delete point: right click point and select Delete Point. - Delete polygon: right click point or line and select Delete Polygon. -Polygon annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New polygons use the selected segment from "Annotations". ### Ruler -When the ruler tool selected, the left mouse button is used to place and adjust ruler end-markers. Right clicking on a end-marker displays a pop-up menu for deleting that ruler. Switch to the "Annotations" tab to see a list of annotations made to currently loaded data. Select the location icon next to a listed ruler to jump to its slice. Select the trashcan to delete that ruler. +When the ruler tool is selected, the left mouse button places and adjusts ruler +end-markers. Right clicking an end-marker displays a menu for deleting that ruler. +Expand its segment in "Annotations" to see its length, jump to its slice or cine +frame, or delete it. -Ruler annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New rulers use the selected segment from "Annotations". ![2D Annotations](./assets/11-volview-paint-notes.jpg) -### Label Configuration - -If VolView loads a JSON file matching the schemas below, labels are added to the 2D annotation tools. -Example configuration JSON: - -```json -{ - "labels": { - "rulerLabels": { - "big": { "color": "#ff0000" }, - "small": { "color": "white" } - }, - "rectangleLabels": { - "innocuous": { "color": "white", "fillColor": "#00ff0030" }, - "lesion": { "color": "#ff0000", "fillColor": "transparent" }, - "tumor": { "color": "green", "fillColor": "transparent" } - } - } -} -``` +### Segment configuration -Label sections could be null to disable labels for a tool. +If VolView loads a JSON file matching the schema below, segments are added to the +registry. Paint, rectangles, polygons and rulers all share `segments`. Appearance +fields are optional. See [segment configuration](./configuration_file.md#segments) +for replacement behavior and how omitted fields use session appearance or defaults. ```json { - "labels": { - "rulerLabels": null, - "rectangleLabels": { - "innocuous": { - "color": "white", - "fillColor": "#00ff0030" - }, - "lesion": { - "color": "#ff0000", - "fillColor": "transparent" - } - } + "segments": { + "innocuous": { "color": "white" }, + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3 } } } ``` -Tools will fallback to `defaultLabels` section if the tool has no specific labels property, -ie `rectangleLabels` or `rulerLabels`. - -```json -{ - "labels": { - "defaultLabels": { - "artifact": { "color": "gray" }, - "needs-review": { "color": "#FFBF00" } - } - } -} -``` +The section can be `null` or `{}` to clear what an earlier config contributed. A segment your +content still references survives as a session segment rather than taking its masks and +shapes with it. ## 3D Crop diff --git a/src/__tests__/segmentGroupRemoval.spec.ts b/src/__tests__/segmentGroupRemoval.spec.ts new file mode 100644 index 000000000..5df19fdaa --- /dev/null +++ b/src/__tests__/segmentGroupRemoval.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { exists, hits, isTest, read, sourceFiles } from './sourceAudit'; + +// Source-level checks keep deleted group infrastructure from returning. + +const SCALAR_PROBE = 'src/components/tools/ScalarProbe.vue'; +const SEGMENTATION_REPRESENTATION = + 'src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue'; + +const DELETED = [ + 'src/store/segmentGroups.ts', + 'src/store/view-configs/segmentGroups.ts', + 'src/components/SegmentGroupControls.vue', + 'src/components/SegmentGroupOpacity.vue', +]; + +/** Production source: a member kept alive only by its own spec is still dead. */ +const production = sourceFiles(import.meta.url, 'src').filter( + (rel) => !isTest(rel) +); + +describe('the group layer is deleted', () => { + it.each(DELETED)('has no %s', (rel) => { + expect(exists(rel)).toBe(false); + }); + + it('has no production reference to the group store', () => { + expect(hits(production, /useSegmentGroupStore/)).toEqual([]); + expect(hits(production, /store\/segmentGroups'/)).toEqual([]); + }); + + it('has no production reference to the per-view group config', () => { + expect( + hits(production, /useSegmentGroupConfigStore|useGlobalSegmentGroupConfig/) + ).toEqual([]); + expect(hits(production, /view-configs\/segmentGroups'/)).toEqual([]); + }); + + it('has no per-parent artifact order left to keep in step', () => { + expect(hits(production, /artifactOrderByParent|artifactsForImage/)).toEqual( + [] + ); + }); + + it('leaves no spec asserting against the deleted module', () => { + const specs = sourceFiles(import.meta.url, 'src').filter(isTest); + expect(hits(specs, /store\/segmentGroups'|useSegmentGroupStore/)).toEqual( + [] + ); + }); +}); + +describe('the value-keyed projection has one publisher', () => { + it('reads segment names from the store projection in the probe', () => { + const source = read(SCALAR_PROBE); + expect(source).toContain('labelmapDescriptorByMask'); + // A computed label-value key would duplicate the store projection. + expect(source).not.toMatch(/\[[^\]]*labelValue[^\]]*\]\s*:/); + }); + + it('declares the outline settings once, on the segment model', () => { + // The segment model is the sole owner of outline settings. + expect(hits(production, /SegmentGroupConfig/)).toEqual([]); + expect(read(SEGMENTATION_REPRESENTATION)).toMatch(/outlineThickness/); + }); +}); diff --git a/src/__tests__/sourceAudit.ts b/src/__tests__/sourceAudit.ts new file mode 100644 index 000000000..90e4f916f --- /dev/null +++ b/src/__tests__/sourceAudit.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Every path here is repo-relative and POSIX-keyed, so a spec's own path +// constants compare equal on Windows too. + +export const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); + +const SOURCE_EXTENSIONS = ['.ts', '.js', '.vue']; +const SKIPPED_DIRS = new Set(['node_modules', 'emscripten-build', 'dist']); + +const toPosix = (rel: string) => rel.split(path.sep).join('/'); + +const relativeToRoot = (full: string) => toPosix(path.relative(repoRoot, full)); + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return SKIPPED_DIRS.has(entry.name) ? [] : walk(full); + } + return SOURCE_EXTENSIONS.includes(path.extname(entry.name)) ? [full] : []; + }); +} + +/** + * Every source file under `dirs`, minus the calling spec: pass + * `import.meta.url` so a spec never audits its own literals. + */ +export function sourceFiles(specUrl: string, ...dirs: string[]) { + const self = relativeToRoot(fileURLToPath(specUrl)); + return dirs + .flatMap((dir) => walk(path.resolve(repoRoot, dir))) + .map(relativeToRoot) + .filter((rel) => rel !== self); +} + +export const read = (rel: string) => + fs.readFileSync(path.resolve(repoRoot, rel), 'utf-8'); + +export const exists = (rel: string) => + fs.existsSync(path.resolve(repoRoot, rel)); + +export const isTest = (rel: string) => rel.split('/').includes('__tests__'); + +/** `file:line` for every line of `files` matching `pattern`. */ +export function hits(files: string[], pattern: RegExp) { + return files.flatMap((rel) => + read(rel) + .split('\n') + .flatMap((line, index) => + pattern.test(line) ? [`${rel}:${index + 1}`] : [] + ) + ); +} diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index e1ef6ad7a..5b5904fc8 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -8,7 +8,7 @@ import { import useLoadDataStore from '@/src/store/load-data'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useLayersStore } from '@/src/store/datasets-layers'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; import { wrapInArray, nonNullable, partition } from '@/src/utils'; import { basename } from '@/src/utils/path'; import { parseUrl } from '@/src/utils/url'; @@ -90,7 +90,7 @@ function sortByDataSourceName(a: LoadableResult, b: LoadableResult) { // does not pick segmentation or layer images function findBaseImage( loadableDataSources: Array, - segmentGroupExtension: string, + segmentationExtension: string, layerExtension: string ) { const baseImages = loadableDataSources @@ -99,7 +99,7 @@ function findBaseImage( const name = getDataSourceName(importResult.dataSource); if (!name) return false; return ( - !isSegmentation(segmentGroupExtension, name) && + !isSegmentation(segmentationExtension, name) && !isSegmentation(layerExtension, name) ); }); @@ -149,7 +149,7 @@ function getStudyUID(volumeID: string) { function findBaseDataSource( succeeded: Array, - segmentGroupExtension: string, + segmentationExtension: string, layerExtension: string ) { const loadableDataSources = filterLoadableDataSources(succeeded); @@ -158,7 +158,7 @@ function findBaseDataSource( const baseImage = findBaseImage( loadableDataSources, - segmentGroupExtension, + segmentationExtension, layerExtension ); if (baseImage) return baseImage; @@ -228,18 +228,18 @@ function autoLayerByName( }); } -// Loads other DataSources as Segment Groups: +// Loads other DataSources as SegmentMask Groups: // - DICOM SEG modalities with matching StudyUIDs. // - DataSources that have a name like foo.segmentation.bar and the primary DataSource is named foo.baz function loadSegmentations( primaryDataSource: LoadableVolumeResult, succeeded: Array, - segmentGroupExtension: string + segmentationExtension: string ) { const matchingNames = filterMatchingNames( primaryDataSource, succeeded, - segmentGroupExtension + segmentationExtension ) .filter( isVolumeResult // filter out models @@ -256,10 +256,10 @@ function loadSegmentations( return modality.trim() === 'SEG'; }); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); [...otherSegVolumesInStudy, ...matchingNames].forEach((ds) => { const loadable = toDataSelection(ds); - segmentGroupStore.convertImageToLabelmap( + segmentationStore.startLabelmapConversion( loadable, toDataSelection(primaryDataSource) ); @@ -307,7 +307,7 @@ function loadDataSourcesWithOutcome( if (succeeded.length && shouldShowData) { const primaryDataSource = findBaseDataSource( succeeded, - loadDataStore.segmentGroupExtension, + loadDataStore.segmentationExtension, loadDataStore.layerExtension ); @@ -323,7 +323,7 @@ function loadDataSourcesWithOutcome( loadSegmentations( primaryDataSource, succeeded, - loadDataStore.segmentGroupExtension + loadDataStore.segmentationExtension ); } // else must be primaryDataSource.type === 'model', which are not dealt with here yet } diff --git a/src/assets/eyedropper-cursor.svg b/src/assets/eyedropper-cursor.svg new file mode 100644 index 000000000..7aefeb08a --- /dev/null +++ b/src/assets/eyedropper-cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/AnnotationsModule.vue b/src/components/AnnotationsModule.vue index ec1456493..f6e0c16b2 100644 --- a/src/components/AnnotationsModule.vue +++ b/src/components/AnnotationsModule.vue @@ -1,85 +1,34 @@ - - + diff --git a/src/components/CineViewer.vue b/src/components/CineViewer.vue index 85e7ee2fa..742e6f5c4 100644 --- a/src/components/CineViewer.vue +++ b/src/components/CineViewer.vue @@ -152,6 +152,7 @@ import vtkMouseCameraTrackballZoomToMouseManipulator from '@kitware/vtk.js/Inter import { useResetViewsEvents } from '@/src/components/tools/ResetViews.vue'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { get2DViewingVectors } from '@/src/utils/getViewingVectors'; +import { isToolVisible } from '@/src/composables/annotationTool'; import type { LPSAxis } from '@/src/types/lps'; type Props = { @@ -200,10 +201,10 @@ const selectionPoints = computed(() => { return { store, tool: store.toolByID[sel.id] }; }) .filter( - ({ tool }) => + ({ store, tool }) => tool.imageID === currentImageID.value && tool.frame === currentFrame.value && - !tool.hidden + isToolVisible(store, tool) ) .flatMap(({ store, tool }) => store.getPoints(tool.id)); }); diff --git a/src/components/ColorDot.vue b/src/components/ColorDot.vue deleted file mode 100644 index 267a48cb6..000000000 --- a/src/components/ColorDot.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/src/components/ControlsStripTools.vue b/src/components/ControlsStripTools.vue index e6c02044f..ac3f0be04 100644 --- a/src/components/ControlsStripTools.vue +++ b/src/components/ControlsStripTools.vue @@ -78,40 +78,31 @@ v-slot:default="{ active, toggle }" :value="Tools.Rectangle" > - - - + /> - - - + /> - - - + />
@@ -146,9 +137,6 @@ import { toRef } from 'vue'; import MenuControlButton from '@/src/components/MenuControlButton.vue'; import CropControls from '@/src/components/tools/crop/CropControls.vue'; import ResetViews from '@/src/components/tools/ResetViews.vue'; -import RulerControls from '@/src/components/RulerControls.vue'; -import RectangleControls from '@/src/components/RectangleControls.vue'; -import PolygonControls from '@/src/components/PolygonControls.vue'; import WindowLevelControls from '@/src/components/tools/windowing/WindowLevelControls.vue'; import { actionToKey, @@ -166,9 +154,6 @@ export default defineComponent({ GroupableItem, CropControls, ResetViews, - RulerControls, - RectangleControls, - PolygonControls, WindowLevelControls, }, setup() { diff --git a/src/components/EditableChipList.vue b/src/components/EditableChipList.vue deleted file mode 100644 index fb48041c8..000000000 --- a/src/components/EditableChipList.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/src/components/EditableItemList.vue b/src/components/EditableItemList.vue new file mode 100644 index 000000000..49d0185fd --- /dev/null +++ b/src/components/EditableItemList.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/src/components/GaussianSmoothParameterControls.vue b/src/components/GaussianSmoothParameterControls.vue index 46cbd9d14..4ffd52aad 100644 --- a/src/components/GaussianSmoothParameterControls.vue +++ b/src/components/GaussianSmoothParameterControls.vue @@ -37,8 +37,8 @@ import { useGaussianSmoothStore, MIN_SIGMA, MAX_SIGMA, -} from '@/src/store/tools/gaussianSmooth'; -import { usePaintProcessStore } from '@/src/store/tools/paintProcess'; +} from '@/src/segmentation/editing/gaussianSmooth'; +import { usePaintProcessStore } from '@/src/segmentation/editing/paintProcess'; import MiniExpansionPanel from './MiniExpansionPanel.vue'; const gaussianSmoothStore = useGaussianSmoothStore(); diff --git a/src/components/ImageDataBrowser.vue b/src/components/ImageDataBrowser.vue index b4d7b807b..7fa501267 100644 --- a/src/components/ImageDataBrowser.vue +++ b/src/components/ImageDataBrowser.vue @@ -4,7 +4,7 @@ import ItemGroup from '@/src/components/ItemGroup.vue'; import GroupableItem from '@/src/components/GroupableItem.vue'; import ImageListCard from '@/src/components/ImageListCard.vue'; import { createVTKImageThumbnailer } from '@/src/core/thumbnailers/vtk-image'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; import { isRegularImage, type DataSelection, @@ -37,7 +37,7 @@ export default defineComponent({ const imageStore = useImageStore(); const dataStore = useDatasetStore(); const layersStore = useLayersStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); const viewSliceStore = useViewSliceStore(); const viewCameraStore = useViewCameraStore(); const imageCacheStore = useImageCacheStore(); @@ -78,6 +78,8 @@ export default defineComponent({ spacing: [...metadata.spacing].map((s) => s.toFixed(2)), layerable, layerLoading, + convertingToSegmentation: + segmentationStore.convertingLabelmaps.has(id), isLayer, layerHandler: () => { if (!layerLoading && layerable) { @@ -155,7 +157,7 @@ export default defineComponent({ function convertToLabelMap(key: string) { if (currentImageID.value) { - segmentGroupStore.convertImageToLabelmap(key, currentImageID.value); + segmentationStore.startLabelmapConversion(key, currentImageID.value); } } @@ -274,6 +276,15 @@ export default defineComponent({ @click="select" @dragstart="onDragStart(image.id, $event)" > +
mdi-alert - Add as Segment Group + Add as segmentation -import { computed, ref, reactive } from 'vue'; -import EditableChipList from '@/src/components/EditableChipList.vue'; -import { LabelsStore } from '@/src/store/tools/useLabels'; -import type { AnnotationTool } from '@/src/types/annotation-tool'; -import { Maybe } from '@/src/types'; -import ToolLabelEditor from '@/src/components/ToolLabelEditor.vue'; -import IsolatedDialog from '@/src/components/IsolatedDialog.vue'; -import { nonNullable } from '@/src/utils'; -import { NO_NAME } from '@/src/constants'; - -const props = defineProps<{ - labelsStore: LabelsStore>; -}>(); - -const labels = computed(() => - Object.entries(props.labelsStore.labels).map(([id, label]) => ({ - id, - name: label.labelName ?? NO_NAME, - color: label.color, - })) -); - -const selectedLabel = computed({ - get: () => props.labelsStore.activeLabel, - set: (id: string | undefined) => { - if (id != null) props.labelsStore.setActiveLabel(id); - }, -}); - -// --- editing state --- // - -type LabelID = string; -const editingLabelID = ref>(undefined); -const editDialog = ref(false); -const editState = reactive({ - labelName: '', - strokeWidth: 1, - color: '', -}); - -const editingLabel = computed(() => { - if (!editingLabelID.value) return null; - return props.labelsStore.labels[editingLabelID.value]; -}); - -const invalidNames = computed(() => { - const names = new Set( - Object.values(props.labelsStore.labels) - .map(({ labelName }) => labelName) - .filter(nonNullable) - ); - const currentName = editingLabel.value?.labelName; - if (currentName) names.delete(currentName); // allow current name - return names; -}); - -const makeUniqueName = (name: string) => { - const existingNames = new Set( - Object.values(props.labelsStore.labels).map((label) => label.labelName) - ); - let uniqueName = name; - let i = 1; - while (existingNames.has(uniqueName)) { - uniqueName = `${name} (${i})`; - i++; - } - return uniqueName; -}; - -const createLabel = () => { - const labelName = makeUniqueName('New Label'); - editingLabelID.value = props.labelsStore.addLabel({ labelName }); -}; - -function startEditing(label: LabelID) { - editDialog.value = true; - editingLabelID.value = label; - if (editingLabel.value) { - editState.labelName = editingLabel.value.labelName ?? ''; - editState.strokeWidth = editingLabel.value.strokeWidth ?? 0; - editState.color = editingLabel.value.color ?? ''; - } -} - -function stopEditing(commit: boolean) { - if (editingLabelID.value && commit) { - props.labelsStore.updateLabel(editingLabelID.value, editState); - } - editDialog.value = false; - editingLabelID.value = null; -} - -function deleteEditingLabel() { - if (editingLabelID.value) { - props.labelsStore.deleteLabel(editingLabelID.value); - } - stopEditing(false); -} - - - - - diff --git a/src/components/LabelEditor.vue b/src/components/LabelEditor.vue index b2535f29d..0ffa30573 100644 --- a/src/components/LabelEditor.vue +++ b/src/components/LabelEditor.vue @@ -2,14 +2,18 @@ import { computed, toRefs } from 'vue'; const emit = defineEmits(['done', 'cancel', 'delete', 'update:color']); -const props = defineProps<{ color: string; valid: boolean }>(); +const props = defineProps<{ + color: string; + valid: boolean; + disabledReason?: string; +}>(); const { color, valid } = toRefs(props); const doneDisabled = computed(() => { - return !valid.value; + return !valid.value || !!props.disabledReason; }); const done = () => { - emit('done'); + if (!doneDisabled.value) emit('done'); }; const cancel = () => { @@ -17,6 +21,7 @@ const cancel = () => { }; const onDelete = () => { + if (props.disabledReason) return; emit('delete'); emit('done'); }; @@ -26,29 +31,59 @@ const onDelete = () => { -
-
+
+
- - Delete - + + + Delete + + {{ disabledReason }} + Cancel - - Done - + + Done + + {{ disabledReason || 'Choose a unique name' }} +
{ + + diff --git a/src/components/MeasurementRulerDetails.vue b/src/components/MeasurementRulerDetails.vue deleted file mode 100644 index 725f15618..000000000 --- a/src/components/MeasurementRulerDetails.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/src/components/MeasurementToolDetails.vue b/src/components/MeasurementToolDetails.vue deleted file mode 100644 index b7501daaf..000000000 --- a/src/components/MeasurementToolDetails.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/components/MeasurementsToolList.vue b/src/components/MeasurementsToolList.vue index 794071cfa..ca05769da 100644 --- a/src/components/MeasurementsToolList.vue +++ b/src/components/MeasurementsToolList.vue @@ -1,237 +1,247 @@ - - diff --git a/src/components/SegmentGroupOpacity.vue b/src/components/SegmentGroupOpacity.vue deleted file mode 100644 index 8bb4235f5..000000000 --- a/src/components/SegmentGroupOpacity.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - diff --git a/src/components/SegmentList.vue b/src/components/SegmentList.vue deleted file mode 100644 index d277bf0df..000000000 --- a/src/components/SegmentList.vue +++ /dev/null @@ -1,284 +0,0 @@ - - - - - diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index 09707c9d5..aade96da1 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -105,10 +105,11 @@ :axis="viewAxis" > @@ -162,7 +163,7 @@ @@ -179,8 +180,8 @@ import VtkSliceView from '@/src/components/vtk/VtkSliceView.vue'; import { VtkViewApi } from '@/src/types/vtk-types'; import { Tools } from '@/src/store/tools/types'; import VtkBaseSliceRepresentation from '@/src/components/vtk/VtkBaseSliceRepresentation.vue'; -import VtkSegmentationSliceRepresentation from '@/src/components/vtk/VtkSegmentationSliceRepresentation.vue'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import VtkSegmentationSliceRepresentation from '@/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue'; +import { useSegmentationStore } from '@/src/segmentation/store'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; import CropTool from '@/src/components/tools/crop/CropTool.vue'; @@ -197,7 +198,10 @@ import SliceSlider from '@/src/components/SliceSlider.vue'; import SliceViewerOverlay from '@/src/components/SliceViewerOverlay.vue'; import { useToolSelectionStore } from '@/src/store/tools/toolSelection'; import { useAnnotationToolStore, useToolStore } from '@/src/store/tools'; -import { doesToolFrameMatchViewAxis } from '@/src/composables/annotationTool'; +import { + doesToolFrameMatchViewAxis, + isToolVisible, +} from '@/src/composables/annotationTool'; import { useWebGLWatchdog } from '@/src/composables/useWebGLWatchdog'; import { useSliceConfig } from '@/src/composables/useSliceConfig'; import VtkSliceViewWindowManipulator from '@/src/components/vtk/VtkSliceViewWindowManipulator.vue'; @@ -273,10 +277,10 @@ onVTKEvent(currentImageData, 'onModified', () => { vtkView.value?.requestRender(); }); -const segmentations = computed(() => { +// One actor per segment, stacked by its place in `segmentation.order`. +const segmentLayers = computed(() => { if (!currentImageID.value) return []; - const store = useSegmentGroupStore(); - return store.orderByParent[currentImageID.value]; + return useSegmentationStore().maskLayersForImage(currentImageID.value); }); // --- selection points --- // @@ -289,9 +293,9 @@ const selectionPoints = computed(() => { return { store, tool: store.toolByID[sel.id] }; }) .filter( - ({ tool }) => + ({ store, tool }) => tool.slice === currentSlice.value && - !tool.hidden && + isToolVisible(store, tool) && doesToolFrameMatchViewAxis(viewAxis, tool, currentImageMetadata) ) .flatMap(({ store, tool }) => store.getPoints(tool.id)); diff --git a/src/components/ToolControls.vue b/src/components/ToolControls.vue index 01aac8195..038a87985 100644 --- a/src/components/ToolControls.vue +++ b/src/components/ToolControls.vue @@ -1,94 +1,7 @@ - - diff --git a/src/components/ToolLabelEditor.vue b/src/components/ToolLabelEditor.vue deleted file mode 100644 index cfa1ebb71..000000000 --- a/src/components/ToolLabelEditor.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - diff --git a/src/components/__tests__/LabelEditor.spec.ts b/src/components/__tests__/LabelEditor.spec.ts new file mode 100644 index 000000000..18d253422 --- /dev/null +++ b/src/components/__tests__/LabelEditor.spec.ts @@ -0,0 +1,58 @@ +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import LabelEditor from '@/src/components/LabelEditor.vue'; + +const Button = defineComponent({ + props: ['disabled'], + template: '', +}); + +const Shell = { template: '
' }; + +const mountEditor = () => + mount(LabelEditor, { + props: { color: '#ff0000', valid: true }, + global: { + stubs: { + VCard: Shell, + VCardItem: Shell, + VCardActions: Shell, + VBtn: Button, + VTooltip: Shell, + VSpacer: true, + VColorPicker: true, + }, + }, + }); + +describe('editor actions while a segment becomes locked', () => { + it.each(['Delete', 'Done'])( + 'disables and guards %s until unlocking', + async (action) => { + const wrapper = mountEditor(); + const button = wrapper + .findAllComponents(Button) + .find((candidate) => candidate.text() === action)!; + await wrapper.setProps({ + disabledReason: 'Unlock this segment to edit or delete it', + }); + expect(button.attributes('disabled')).toBeDefined(); + expect(button.element.parentElement?.textContent).toContain( + 'Unlock this segment' + ); + // A stale UI event must obey the same eligibility as the visible button. + button.vm.$emit('click'); + expect(wrapper.emitted('done')).toBeUndefined(); + expect(wrapper.emitted('delete')).toBeUndefined(); + + await wrapper.setProps({ disabledReason: undefined }); + expect(button.attributes('disabled')).toBeUndefined(); + await button.trigger('click'); + expect( + wrapper.emitted(action === 'Delete' ? 'delete' : 'done') + ).toHaveLength(1); + wrapper.unmount(); + } + ); +}); diff --git a/src/components/__tests__/MeasurementDetails.spec.ts b/src/components/__tests__/MeasurementDetails.spec.ts deleted file mode 100644 index 6c7ba0736..000000000 --- a/src/components/__tests__/MeasurementDetails.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { beforeEach, describe, expect, it } from 'vitest'; -import MeasurementToolDetails from '@/src/components/MeasurementToolDetails.vue'; -import MeasurementRulerDetails from '@/src/components/MeasurementRulerDetails.vue'; -import { useRulerStore } from '@/src/store/tools/rulers'; -import { ToolID } from '@/src/types/annotation-tool'; - -beforeEach(() => { - setActivePinia(createPinia()); -}); - -const stubs = { - 'v-row': { template: '
' }, - 'v-col': { template: '
' }, -}; - -const baseTool = { - id: 'tool-1' as ToolID, - imageID: 'img-1', - frameOfReference: { - planeOrigin: [0, 0, 0] as [number, number, number], - planeNormal: [0, 0, 1] as [number, number, number], - }, - color: '#fff', - name: 'Tool', - axis: 'Axial', -}; - -describe('MeasurementToolDetails', () => { - it('shows slice number for a volume annotation', () => { - const wrapper = mount(MeasurementToolDetails, { - props: { tool: { ...baseTool, slice: 4 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Slice: 5'); - expect(wrapper.text()).not.toContain('Frame:'); - }); - - it('shows frame number for a cine annotation', () => { - const wrapper = mount(MeasurementToolDetails, { - props: { tool: { ...baseTool, slice: 0, frame: 7 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Frame: 8'); - expect(wrapper.text()).not.toContain('Slice:'); - }); -}); - -describe('MeasurementRulerDetails', () => { - // The component reads the length off the ruler store, so the ruler has to - // exist there: a 3-4-5 triangle gives a length of 5.00mm. - const seatRuler = () => - useRulerStore().addRuler({ - firstPoint: [0, 0, 0], - secondPoint: [3, 4, 0], - }); - - it('shows slice number for a volume ruler', () => { - const wrapper = mount(MeasurementRulerDetails, { - props: { tool: { ...baseTool, id: seatRuler(), slice: 9 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Slice: 10'); - expect(wrapper.text()).toContain('5.00mm'); - expect(wrapper.text()).not.toContain('Frame:'); - }); - - it('shows frame number for a cine ruler', () => { - const wrapper = mount(MeasurementRulerDetails, { - props: { tool: { ...baseTool, id: seatRuler(), slice: 0, frame: 2 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Frame: 3'); - expect(wrapper.text()).not.toContain('Slice:'); - }); -}); diff --git a/src/components/__tests__/PatientStudyVolumeBrowser.spec.ts b/src/components/__tests__/PatientStudyVolumeBrowser.spec.ts new file mode 100644 index 000000000..df45e9c1e --- /dev/null +++ b/src/components/__tests__/PatientStudyVolumeBrowser.spec.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { defineComponent, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import PatientStudyVolumeBrowser from '@/src/components/PatientStudyVolumeBrowser.vue'; +import { seatVolume } from '@/src/store/__tests__/datasetFixtures'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import type { ProgressiveImage } from '@/src/core/progressiveImage'; + +const SlotStub = defineComponent({ + template: '
', +}); + +const mountBrowser = () => + mount(PatientStudyVolumeBrowser, { + props: { volumeKeys: ['seg-volume'] }, + global: { + stubs: { + GroupableItem: { + template: '
', + }, + PersistentOverlay: { + props: ['disabled'], + template: '
', + }, + VImg: { + template: '
', + }, + VProgressCircular: { template: '
' }, + VContainer: SlotStub, + VRow: SlotStub, + VCol: SlotStub, + VCard: SlotStub, + VCardText: SlotStub, + VCheckbox: true, + VBtn: true, + VMenu: true, + VList: true, + VListItem: true, + VIcon: true, + VTooltip: true, + VSpacer: true, + }, + }, + }); + +describe('DICOM segmentation conversion progress', () => { + beforeEach(() => { + setActivePinia(createPinia()); + seatVolume('seg-volume', { + Modality: 'SEG', + SeriesDescription: 'TotalSegmentator segmentation', + }); + useImageCacheStore().imageById['seg-volume'] = { + getThumbnail: () => Promise.resolve(null), + } as ProgressiveImage; + }); + + it('covers the source thumbnail while it is becoming a segmentation', async () => { + const segmentations = useSegmentationStore(); + segmentations.convertingLabelmaps.add('seg-volume'); + const wrapper = mountBrowser(); + await nextTick(); + + const progress = wrapper.find( + '[data-testid="segmentation-conversion-progress"]' + ); + expect(progress.exists()).toBe(true); + expect(progress.text()).toContain('Adding segmentation'); + expect(wrapper.findAll('.progress')).toHaveLength(1); + + segmentations.convertingLabelmaps.delete('seg-volume'); + await nextTick(); + expect( + wrapper.find('[data-testid="segmentation-conversion-progress"]').exists() + ).toBe(false); + }); +}); diff --git a/src/components/processes.ts b/src/components/processes.ts index d16aff121..353f13997 100644 --- a/src/components/processes.ts +++ b/src/components/processes.ts @@ -2,14 +2,14 @@ import type { Component } from 'vue'; import { ProcessType, type ProcessAlgorithm, -} from '@/src/store/tools/paintProcess'; +} from '@/src/segmentation/editing/paintProcess'; import { useFillHolesStore, FillHolesSegmentScope, -} from '@/src/store/tools/fillHoles'; -import { useFillBetweenStore } from '@/src/store/tools/fillBetween'; -import { useGaussianSmoothStore } from '@/src/store/tools/gaussianSmooth'; -import FillHolesParameterControls from './FillHolesParameterControls.vue'; +} from '@/src/segmentation/editing/fillHoles'; +import { useFillBetweenStore } from '@/src/segmentation/editing/fillBetween'; +import { useGaussianSmoothStore } from '@/src/segmentation/editing/gaussianSmooth'; +import FillHolesParameterControls from '@/src/segmentation/components/FillHolesParameterControls.vue'; import FillBetweenParameterControls from './FillBetweenParameterControls.vue'; import GaussianSmoothParameterControls from './GaussianSmoothParameterControls.vue'; diff --git a/src/components/styles/annotation-panels.css b/src/components/styles/annotation-panels.css new file mode 100644 index 000000000..59b3d439c --- /dev/null +++ b/src/components/styles/annotation-panels.css @@ -0,0 +1,21 @@ +.annotation-panels { + width: 100%; +} + +.annotation-panels .v-expansion-panel-title { + min-height: 48px; + padding-inline: 16px; +} + +.annotation-panels .annotation-panel-icon { + flex: 0 0 auto; + margin-inline-end: 12px; +} + +.annotation-panels .v-expansion-panel-text__wrapper { + padding: 8px 12px 12px; +} + +.annotation-panels .v-expansion-panel::after { + border-top: 0; +} diff --git a/src/components/tools/AnnotationContextMenu.vue b/src/components/tools/AnnotationContextMenu.vue index 92afe0925..d89a4d695 100644 --- a/src/components/tools/AnnotationContextMenu.vue +++ b/src/components/tools/AnnotationContextMenu.vue @@ -6,6 +6,7 @@ import { WidgetAction, } from '@/src/vtk/ToolWidgetUtils/types'; import { ToolID } from '@/src/types/annotation-tool'; +import { useToolAppearance } from '@/src/composables/annotationTool'; const props = defineProps<{ toolStore: AnnotationToolStore; @@ -35,6 +36,8 @@ const tool = computed(() => { return props.toolStore.toolByID[contextMenu.forToolID]; }); +const appearance = useToolAppearance(props.toolStore, () => tool.value); + const deleteToolFromContextMenu = () => { props.toolStore.removeTool(contextMenu.forToolID); }; @@ -61,11 +64,11 @@ const hideToolFromContextMenu = () => { - {{ tool.labelName }} + {{ appearance.name }} diff --git a/src/components/tools/AnnotationInfo.vue b/src/components/tools/AnnotationInfo.vue index 6ae3d1ac2..7d1b1cbc3 100644 --- a/src/components/tools/AnnotationInfo.vue +++ b/src/components/tools/AnnotationInfo.vue @@ -30,7 +30,8 @@ const metadata = computed(() => { const label = computed(() => { if (!props.info.visible) return ''; - return props.toolStore.toolByID[props.info.toolID].labelName; + const { segmentId } = props.toolStore.toolByID[props.info.toolID]; + return props.toolStore.segments.appearanceOf(segmentId).name; }); const tooltip = ref(); diff --git a/src/components/tools/ScalarProbe.vue b/src/components/tools/ScalarProbe.vue index c962c82b1..5d25d0537 100644 --- a/src/components/tools/ScalarProbe.vue +++ b/src/components/tools/ScalarProbe.vue @@ -8,7 +8,8 @@ import { VtkViewContext } from '@/src/components/vtk/context'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import vtkPointPicker from '@kitware/vtk.js/Rendering/Core/PointPicker'; import { useSliceRepresentation } from '@/src/core/vtk/useSliceRepresentation'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useProbeStore } from '@/src/store/probe'; import { useImageCacheStore } from '@/src/store/image-cache'; import { NO_NAME } from '@/src/constants'; @@ -18,10 +19,10 @@ type SliceRepresentationType = ReturnType; const props = defineProps<{ baseRep: SliceRepresentationType; layerReps: SliceRepresentationType[]; - segmentGroupsReps: SliceRepresentationType[]; + segmentReps: SliceRepresentationType[]; }>(); -const { baseRep, layerReps, segmentGroupsReps } = toRefs(props); +const { baseRep, layerReps, segmentReps } = toRefs(props); const view = inject(VtkViewContext); if (!view) throw new Error('No VtkView'); @@ -32,7 +33,8 @@ const { currentLayers, } = useCurrentImage(); const imageCacheStore = useImageCacheStore(); -const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); +const { segments: segments } = useSegmentStore(); const probeStore = useProbeStore(); // Helper functions to build a unified sample set @@ -65,22 +67,29 @@ const getLayers = () => }) .filter(Boolean); +// Paired positionally with the slice view's segment actors, which come off the +// same ordered list. const getSegments = () => { if (!currentImageID.value) return []; - const parentGroups = segmentGroupStore.orderByParent[currentImageID.value]; - if (!parentGroups) return []; - return segmentGroupsReps.value + const layers = segmentationStore.maskLayersForImage(currentImageID.value); + return segmentReps.value .map((rep, index) => { - const groupId = parentGroups[index]; - if (!groupId) return null; - const meta = segmentGroupStore.metadataByID[groupId]; + const layer = layers[index]; + if (!layer) return null; + const segment = segmentationStore.getMask(layer.maskId); + const voxels = segmentationStore.findMaskVoxels(layer.maskId); + if (!voxels.exists()) return null; + const descriptor = + segmentationStore.labelmapDescriptorByMask[layer.maskId]; return { - type: 'segmentGroup', - id: groupId, - name: meta.name, + type: 'segment', + id: layer.maskId, + name: segments.appearanceOf(segment.segmentId).name, rep, - segments: meta.segments, - image: segmentGroupStore.dataIndex[groupId], + nameByLabelValue: descriptor + ? { [descriptor.value]: descriptor.name } + : {}, + image: voxels.image(), }; }) .filter(Boolean); @@ -144,11 +153,14 @@ const getImageSamples = (x: number, y: number) => { const scalars = scalarData.getTuple(index) as number[]; const baseInfo = { id: item.id, name: item.name }; - if (item.type === 'segmentGroup') { + if (item.type === 'segment') { + // A mask's bounding box can contain empty voxels from other segments. + if (scalars.every((value) => value === 0)) return null; + return { ...baseInfo, displayValues: scalars.map( - (v) => item.segments.byValue[v]?.name || 'Background' + (v) => item.nameByLabelValue[v] || 'Background' ), }; } diff --git a/src/components/tools/__tests__/ScalarProbe.spec.ts b/src/components/tools/__tests__/ScalarProbe.spec.ts new file mode 100644 index 000000000..05bfd64b1 --- /dev/null +++ b/src/components/tools/__tests__/ScalarProbe.spec.ts @@ -0,0 +1,150 @@ +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import ScalarProbe from '@/src/components/tools/ScalarProbe.vue'; +import { VtkViewContext } from '@/src/components/vtk/context'; +import { useProbeStore } from '@/src/store/probe'; + +import * as currentImage from '@/src/composables/useCurrentImage'; +import * as vtkEvent from '@/src/composables/onVTKEvent'; +import vtkPointPicker from '@kitware/vtk.js/Rendering/Core/PointPicker'; +import * as imageCache from '@/src/store/image-cache'; +import * as segments from '@/src/segmentation/segments'; +import * as segmentations from '@/src/segmentation/store'; + +const state = { + current: {} as ReturnType, + masks: [] as { id: string; image: vtkImageData }[], + events: {} as Record void>, +}; + +function image(values: number[], components = 1) { + const result = vtkImageData.newInstance(); + result.setDimensions(3, 1, 1); + result.getPointData().setScalars( + vtkDataArray.newInstance({ + values: new Float32Array(values), + numberOfComponents: components, + }) + ); + return result; +} + +function probe() { + const rep = {} as InstanceType['$props']['baseRep']; + const wrapper = mount(ScalarProbe, { + props: { + baseRep: rep, + layerReps: [rep], + segmentReps: state.masks.map(() => rep), + }, + global: { + provide: { [VtkViewContext as symbol]: { renderer: {}, interactor: {} } }, + }, + }); + state.events.onMouseMove({ position: { x: 10, y: 20 } }); + const result = useProbeStore().probeData; + wrapper.unmount(); + return result; +} + +describe('ScalarProbe segment samples', () => { + afterEach(() => vi.restoreAllMocks()); + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + state.current = { + currentImageID: ref('ct'), + currentImageData: ref(image([-100, 42, 100])), + currentImageMetadata: ref({ name: 'CT' }), + currentLayers: ref([{ id: 'overlay', selection: 'overlay' }]), + } as ReturnType; + state.masks = []; + state.events = {}; + vi.spyOn(currentImage, 'useCurrentImage').mockImplementation( + () => state.current + ); + vi.spyOn(vtkEvent, 'onVTKEvent').mockImplementation( + (_target, name, callback) => { + state.events[name] = callback; + return { stop: () => {} }; + } + ); + const picker = { ...vtkPointPicker.newInstance() }; + vi.spyOn(picker, 'pick').mockImplementation(() => {}); + vi.spyOn(picker, 'getActors').mockReturnValue([ + {} as ReturnType[number], + ]); + vi.spyOn(picker, 'getPointIJK').mockReturnValue([1, 0, 0]); + vi.spyOn(vtkPointPicker, 'newInstance').mockReturnValue(picker); + const cache = imageCache.useImageCacheStore(); + vi.spyOn(cache, 'getImageMetadata').mockReturnValue({ + name: 'Overlay', + } as ReturnType); + vi.spyOn(cache, 'getVtkImageData').mockReturnValue(image([0, 0, 0])); + const registry = segments.useSegmentStore(); + vi.spyOn(registry.segments, 'appearanceOf').mockImplementation( + (id) => + ({ name: id }) as ReturnType + ); + const store = segmentations.useSegmentationStore(); + vi.spyOn(store, 'maskLayersForImage').mockImplementation(() => + state.masks.map(({ id }) => ({ maskId: id, stackIndex: 0 })) + ); + vi.spyOn(store, 'getMask').mockImplementation( + (id) => ({ segmentId: id }) as ReturnType + ); + vi.spyOn(store, 'findMaskVoxels').mockImplementation( + (id) => + ({ + exists: () => true, + image: () => state.masks.find((mask) => mask.id === id)!.image, + }) as ReturnType + ); + vi.spyOn(store, 'labelmapDescriptorByMask', 'get').mockImplementation(() => + Object.fromEntries( + state.masks.map(({ id }) => [ + id, + { value: 1, name: id, color: [255, 0, 0, 255], visible: true }, + ]) + ) + ); + }); + + it('omits empty mask voxels while retaining the occupied segment, CT, position, and zero image layer', () => { + state.masks = [ + { id: 'Liver', image: image([0, 1, 0]) }, + { id: 'Kidney', image: image([1, 0, 0]) }, + { id: 'Spleen', image: image([0, 0, 1]) }, + ]; + const result = probe(); + expect(Array.from(result!.pos)).toEqual([1, 0, 0]); + expect(result!.samples).toEqual([ + { id: 'Liver', name: 'Liver', displayValues: ['Liver'] }, + { id: 'overlay', name: 'Overlay', displayValues: [0] }, + { id: 'ct', name: 'CT', displayValues: [42] }, + ]); + }); + + it('retains genuinely overlapping segments and masks with any occupied component', () => { + state.masks = [ + { id: 'First', image: image([0, 1, 0]) }, + { id: 'Second', image: image([0, 0, 0, 1, 0, 0], 2) }, + { id: 'Empty', image: image([1, 0, 0, 0, 0, 1], 2) }, + ]; + expect(probe()!.samples.slice(0, 2)).toEqual([ + { id: 'First', name: 'First', displayValues: ['First'] }, + { id: 'Second', name: 'Second', displayValues: ['Background', 'Second'] }, + ]); + expect(probe()!.samples.map(({ id }) => id)).toEqual([ + 'First', + 'Second', + 'overlay', + 'ct', + ]); + }); +}); diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index 93db850b5..f096b5837 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -8,6 +8,7 @@ import { toRefs, watchEffect, inject, + ref, } from 'vue'; import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; import { vec3 } from 'gl-matrix'; @@ -15,14 +16,15 @@ import { getLPSAxisFromDir } from '@/src/utils/lps'; import { useImage } from '@/src/composables/useCurrentImage'; import { updatePlaneManipulatorFor2DView } from '@/src/utils/manipulators'; import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import { vtkPaintViewWidget } from '@/src/vtk/PaintWidget'; import { LPSAxisDir } from '@/src/types/lps'; -import { getLPSDirections } from '@/src/utils/lps'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { useSliceInfo } from '@/src/composables/useSliceInfo'; import { VtkViewContext } from '@/src/components/vtk/context'; import { Maybe } from '@/src/types'; +import { PaintMode } from '@/src/core/tools/paint'; +import { usePaintInteractionMode } from '@/src/segmentation/composables/usePaintInteractionMode'; +import eyedropperCursor from '@/src/assets/eyedropper-cursor.svg?url'; import { useActionHeld } from '@/src/composables/useKeyboardShortcuts'; export default defineComponent({ @@ -48,7 +50,10 @@ export default defineComponent({ const slice = computed(() => sliceInfo.value?.slice); const paintStore = usePaintToolStore(); - const segmentGroupStore = useSegmentGroupStore(); + const interactionMode = usePaintInteractionMode(); + const sampling = computed( + () => interactionMode.value === PaintMode.Eyedropper + ); const widgetFactory = paintStore.getWidgetFactory(); const widgetState = widgetFactory.getWidgetState(); @@ -58,46 +63,39 @@ export default defineComponent({ () => imageMetadata.value.lpsOrientation[viewAxis.value] ); - // Get the active labelmap for coordinate transforms - const activeLabelmap = computed(() => { - const groupId = paintStore.activeSegmentGroupID; - if (!groupId) return null; - return segmentGroupStore.dataIndex[groupId] ?? null; - }); - const widget = view.widgetManager.addWidget( widgetFactory ) as vtkPaintViewWidget; + widget.setPickable(false); // --- widget representation config --- // + // Every mask uses the parent voxel grid. Selection and mask growth do not + // change the brush's world-space footprint. watchEffect(() => { - if (!widget) return; - - const labelmap = activeLabelmap.value; - if (labelmap) { - // Use labelmap's transforms so brush preview matches where paint appears - const labelmapLps = getLPSDirections(labelmap.getDirection()); - const slicingIndex = labelmapLps[viewAxis.value]; - widget.setSlicingIndex(slicingIndex); - widget.setIndexToWorld(labelmap.getIndexToWorld()); - widget.setWorldToIndex(labelmap.getWorldToIndex()); - } else { - // Fall back to parent image transforms - const metadata = imageMetadata.value; - const slicingIndex = metadata.lpsOrientation[viewAxis.value]; - widget.setSlicingIndex(slicingIndex); - widget.setIndexToWorld(metadata.indexToWorld); - widget.setWorldToIndex(metadata.worldToIndex); - } + const metadata = imageMetadata.value; + widget.setSlicingIndex(metadata.lpsOrientation[viewAxis.value]); + widget.setIndexToWorld(metadata.indexToWorld); + widget.setWorldToIndex(metadata.worldToIndex); }); + // Brush movement changes shared state, but only the view displaying the + // preview needs to redraw. Mask edits request renders independently. + onVTKEvent(widgetState, 'onModified', () => { + if (widget.getVisibility()) view.requestRender(); + }); + onVTKEvent(widget, 'onModified', () => view.requestRender()); + // --- interaction --- // - onVTKEvent(widget, 'onStartInteractionEvent', () => { + onVTKEvent(widget, 'onStartInteractionEvent', (event) => { if (!imageId.value) return; - paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); const origin = widgetState.getBrush().getOrigin()!; + if (event?.sampling) { + paintStore.selectSegmentAt(vec3.clone(origin), imageId.value); + return; + } + paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); paintStore.startStroke( vec3.clone(origin), viewAxisIndex.value, @@ -145,26 +143,42 @@ export default defineComponent({ // --- visibility --- // let checkIfPointerInView = false; + const pointerInView = ref(false); + const cursorStyles = view.widgetManager.getCursorStyles(); + watchEffect(() => { + widget.setSampling(sampling.value); + widget.setVisibility(pointerInView.value && !sampling.value); + const cursor = sampling.value + ? `url("${eyedropperCursor}") 2 22, crosshair` + : cursorStyles.default; + view.widgetManager.setCursorStyles( + sampling.value + ? { ...cursorStyles, default: cursor, hover: cursor } + : cursorStyles + ); + view.renderWindowView.set({ cursor }); + }); // Turn on widget visibility and update stencil if mouse starts within view - onVTKEvent(view.interactor, 'onMouseMove', () => { + const showPreviewOnFirstMove = () => { if (!checkIfPointerInView) return; checkIfPointerInView = false; - widget.setVisibility(true); + pointerInView.value = true; if (imageId.value) { paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); } - }); + }; + onVTKEvent(view.interactor, 'onMouseMove', showPreviewOnFirstMove); onVTKEvent(view.interactor, 'onMouseEnter', () => { if (imageId.value) { paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); } - widget.setVisibility(true); + pointerInView.value = true; }); onVTKEvent(view.interactor, 'onMouseLeave', () => { - widget.setVisibility(false); + pointerInView.value = false; }); watchEffect(() => { @@ -183,8 +197,8 @@ export default defineComponent({ }; onMounted(() => { - view.widgetManager.renderWidgets(); view.widgetManager.grabFocus(widget); + view.widgetManager.renderWidgets(); widget.setVisibility(false); checkIfPointerInView = true; view.renderWindowView @@ -193,6 +207,8 @@ export default defineComponent({ }); onUnmounted(() => { + view.widgetManager.setCursorStyles(cursorStyles); + view.renderWindowView.set({ cursor: cursorStyles.default }); view.widgetManager.removeWidget(widgetFactory); view.renderWindowView .getContainer() diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index 89c19634a..c293ad90d 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -10,6 +10,7 @@ :view-id="viewId" :view-direction="viewDirection" @contextmenu="openContextMenu(tool.id, $event)" + @placing="onPlacementStarted" @placed="onToolPlaced" @widgetHover="onHover(tool.id, $event)" /> @@ -19,49 +20,30 @@ :tool-store="activeToolStore" v-slot="{ context }" > - - - Rasterize as... - diff --git a/src/segmentation/components/SegmentAssignmentList.vue b/src/segmentation/components/SegmentAssignmentList.vue new file mode 100644 index 000000000..e1405c4ac --- /dev/null +++ b/src/segmentation/components/SegmentAssignmentList.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/segmentation/components/SegmentEditor.vue b/src/segmentation/components/SegmentEditor.vue new file mode 100644 index 000000000..7b4b0691b --- /dev/null +++ b/src/segmentation/components/SegmentEditor.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/segmentation/components/SegmentList.vue b/src/segmentation/components/SegmentList.vue new file mode 100644 index 000000000..e88730c2f --- /dev/null +++ b/src/segmentation/components/SegmentList.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/src/segmentation/components/SegmentListActions.vue b/src/segmentation/components/SegmentListActions.vue new file mode 100644 index 000000000..42ba2a2e1 --- /dev/null +++ b/src/segmentation/components/SegmentListActions.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts new file mode 100644 index 000000000..1db61db75 --- /dev/null +++ b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createApp, defineComponent, nextTick } from 'vue'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; + +import ProcessWorkflow from '@/src/segmentation/components/ProcessWorkflow.vue'; +import { CorePiniaProviderPlugin } from '@/src/core/provider'; +import { + addActiveSegment, + seatImage, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { + usePaintProcessStore, + type ProcessTarget, +} from '@/src/segmentation/editing/paintProcess'; +import { useViewStore } from '@/src/store/views'; + +// --------------------------------------------------------------------------- +// The Original/Processed pair is a segmented choice, not a switch: the toggle +// is mandatory, so clicking the button already selected keeps the selection +// but still fires the click. Each button therefore states what it shows. +// --------------------------------------------------------------------------- + +const BtnStub = defineComponent({ + name: 'VBtn', + props: ['value', 'prependIcon', 'loading', 'disabled', 'variant'], + template: ``, +}); + +const BtnToggleStub = defineComponent({ + name: 'VBtnToggle', + props: ['modelValue', 'mandatory', 'variant', 'divided', 'density'], + template: `
`, +}); + +const globalOptions = { + stubs: { + VRow: { template: '
' }, + VBtn: BtnStub, + VBtnToggle: BtnToggleStub, + // The icon name is slot text, which would land in the button's label. + VIcon: { template: '' }, + }, +}; + +const processed = async (target: ProcessTarget) => ({ + scalars: new Uint8Array([1, 1]), + extent: target.maskExtent, +}); + +const button = (wrapper: VueWrapper, label: string) => { + const found = wrapper + .findAll('button') + .find((candidate) => candidate.text().trim() === label); + if (!found) throw new Error(`No ${label} button`); + return found; +}; + +const selected = (wrapper: VueWrapper) => + wrapper.get('.btn-toggle').attributes('data-selected'); + +describe('the process preview toggle', () => { + beforeEach(async () => { + const pinia = createPinia().use(CorePiniaProviderPlugin()); + createApp({}).use(pinia); + setActivePinia(pinia); + await seatImage('image-1', { dimensions: [2, 1, 1] }); + useViewStore().setDataForAllViews('image-1'); + await nextTick(); + }); + + const previewing = async () => { + const { labelMap } = addActiveSegment(new Uint8Array([1, 0])); + const wrapper = mount(ProcessWorkflow, { + props: { algorithm: processed }, + global: globalOptions, + }); + await button(wrapper, 'Preview').trigger('click'); + await flushPromises(); + const values = () => + Array.from(labelMap.getPointData().getScalars().getData()); + expect(usePaintProcessStore().processStep).toBe('previewing'); + return { wrapper, values, processStore: usePaintProcessStore() }; + }; + + it('leaves the preview alone when the showing button is clicked again', async () => { + const { wrapper, values, processStore } = await previewing(); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + + await button(wrapper, 'Processed').trigger('click'); + + expect(processStore.showingOriginal).toBe(false); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + }); + + it('shows the original once, however often its button is clicked', async () => { + const { wrapper, values, processStore } = await previewing(); + + await button(wrapper, 'Original').trigger('click'); + + expect(processStore.showingOriginal).toBe(true); + expect(selected(wrapper)).toBe('0'); + expect(values()).toEqual([1, 0]); + + await button(wrapper, 'Original').trigger('click'); + + expect(processStore.showingOriginal).toBe(true); + expect(selected(wrapper)).toBe('0'); + expect(values()).toEqual([1, 0]); + }); + + it('still moves between the two', async () => { + const { wrapper, values, processStore } = await previewing(); + + await button(wrapper, 'Original').trigger('click'); + await button(wrapper, 'Processed').trigger('click'); + + expect(processStore.showingOriginal).toBe(false); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentEditor.spec.ts b/src/segmentation/components/__tests__/SegmentEditor.spec.ts new file mode 100644 index 000000000..98c5b64a8 --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentEditor.spec.ts @@ -0,0 +1,92 @@ +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; + +import SegmentEditor from '@/src/segmentation/components/SegmentEditor.vue'; + +const LabelEditorStub = defineComponent({ + name: 'LabelEditor', + props: ['color', 'valid'], + setup: () => ({ done: () => {} }), + template: '
', +}); + +const TextFieldStub = defineComponent({ + name: 'VTextField', + props: ['modelValue', 'rules'], + template: '', +}); + +const SliderStub = defineComponent({ + name: 'VSlider', + props: ['label', 'modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: '
', +}); + +const mountEditor = () => + mount(SegmentEditor, { + props: { + name: 'Tumor', + original: 'Tumor', + color: '#ff0000', + invalidNames: new Set(['Tumor', 'Node']), + fillOpacity: 1, + outlineOpacity: 1, + strokeWidth: 1, + }, + global: { + stubs: { + LabelEditor: LabelEditorStub, + VTextField: TextFieldStub, + VSlider: SliderStub, + }, + }, + }); + +describe('segment type editor name validation', () => { + it('allows an unchanged duplicate name', () => { + const wrapper = mountEditor(); + + expect(wrapper.findComponent(LabelEditorStub).props('valid')).toBe(true); + const [rule] = wrapper.findComponent(TextFieldStub).props('rules'); + expect(rule('Tumor')).toBe(true); + }); + + it('rejects changing to another type’s name', async () => { + const wrapper = mountEditor(); + + await wrapper.setProps({ name: ' Node ' }); + + expect(wrapper.findComponent(LabelEditorStub).props('valid')).toBe(false); + const [rule] = wrapper.findComponent(TextFieldStub).props('rules'); + expect(rule(' Node ')).toBe('Name is not unique'); + }); +}); + +describe('segment type editor stroke width', () => { + it('offers integer stroke widths from 1 through 5', () => { + const wrapper = mountEditor(); + const strokeWidth = wrapper + .findAllComponents(SliderStub) + .find((slider) => slider.props('label') === 'Stroke Width'); + + expect(strokeWidth?.props()).toMatchObject({ + modelValue: 1, + min: 1, + max: 5, + step: 1, + }); + }); + + it('emits an integer stroke width', () => { + const wrapper = mountEditor(); + const strokeWidth = wrapper + .findAllComponents(SliderStub) + .find((slider) => slider.props('label') === 'Stroke Width')!; + + strokeWidth.vm.$emit('update:modelValue', 3.6); + + expect(wrapper.emitted('update:strokeWidth')).toEqual([[4]]); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentList.spec.ts b/src/segmentation/components/__tests__/SegmentList.spec.ts new file mode 100644 index 000000000..129850230 --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentList.spec.ts @@ -0,0 +1,1432 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + type Index3, + maskOn, + lockSegment, + boundMasks, + seedVoxel, + markedVoxels, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { defineComponent, nextTick, ref } from 'vue'; +import { enableAutoUnmount, mount, VueWrapper } from '@vue/test-utils'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import SegmentList from '@/src/segmentation/components/SegmentList.vue'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useMessageStore } from '@/src/store/messages'; +import useLoadDataStore from '@/src/store/load-data'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { DEFAULT_SEGMENTATION_FILL_OPACITY } from '@/src/segmentation/model'; +import { extentSize, maskOffset } from '@/src/segmentation/geometry'; +import { useViewStore } from '@/src/store/views'; +import useViewSliceStore from '@/src/store/view-configs/slicing'; +import { seatCineImage } from '@/src/core/cine/__tests__/cineFixtures'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { + useCurrentTools, + usePlacingAnnotationTool, +} from '@/src/composables/annotationTool'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { AXIAL_FRAME_OF_REFERENCE } from '@/src/utils/frameOfReference'; +import useCinePlaybackStore from '@/src/store/view-configs/cine-playback'; +import useViewCameraStore from '@/src/store/view-configs/camera'; + +enableAutoUnmount(afterEach); + +// --------------------------------------------------------------------------- +// One flat list of segment types: rows are the shared registry's segments, keyed +// on type id, offered whether or not this image has a mask for them. The +// visibility and lock controls belong to the shared segment, the +// display sliders to its segmentation, and adding a row allocates nothing. +// --------------------------------------------------------------------------- + +const DIMENSIONS = [4, 4, 2] as const; + +const store = () => useSegmentationStore(); +const segments = () => useSegmentStore().segments; + +async function seatImage( + id: string, + name = 'CT', + dimensions: readonly [number, number, number] = DIMENSIONS +) { + const image = vtkImageData.newInstance({ spacing: [1, 1, 1] }); + image.setDimensions(dimensions as unknown as [number, number, number]); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(dimensions[0] * dimensions[1] * dimensions[2]), + }) + ); + image.computeTransforms(); + useImageCacheStore().addVTKImageData(image, name, { id }); + await nextTick(); + return id; +} + +const viewImage = async (id: string) => { + useViewStore().setDataForAllViews(id); + await nextTick(); +}; + +const makeMask = (imageId: string, name: string) => { + const segmentId = segments().mintSegment({ name }); + const record = maskOn(imageId, segmentId); + return { id: segmentId, segmentId, maskId: record.id, record }; +}; + +const makeSegment = (name: string) => segments().mintSegment({ name }); + +// The item list stands in for the real one so the per-row slot renders without +// Vuetify: rows carry their segment id, and the row buttons keep the icon names +// the list uses today. +const ItemListStub = defineComponent({ + name: 'EditableItemList', + props: [ + 'items', + 'itemKey', + 'itemTitle', + 'modelValue', + 'createText', + 'hideCreate', + ], + emits: ['update:model-value', 'create', 'select', 'edit'], + template: ` +
+
+ + +
+
+ `, +}); + +const BtnStub = defineComponent({ + name: 'VBtn', + props: ['icon', 'disabled'], + template: ``, +}); + +const IconStub = defineComponent({ + name: 'VIcon', + template: ``, +}); + +const SegmentEditorStub = defineComponent({ + name: 'SegmentEditor', + props: [ + 'name', + 'original', + 'color', + 'fillOpacity', + 'outlineOpacity', + 'strokeWidth', + 'invalidNames', + 'locked', + ], + emits: [ + 'done', + 'cancel', + 'delete', + 'update:name', + 'update:color', + 'update:fillOpacity', + 'update:outlineOpacity', + 'update:strokeWidth', + ], + template: `
`, +}); + +// Sliders are found by the label the user reads. +const SliderStub = defineComponent({ + name: 'VSlider', + props: ['label', 'modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ``, +}); + +const globalOptions = { + stubs: { + VSlider: SliderStub, + VExpansionPanels: { template: '
' }, + VExpansionPanel: { template: '
' }, + VExpansionPanelTitle: { template: '' }, + VExpansionPanelText: { template: '
' }, + EditableItemList: ItemListStub, + SegmentEditor: SegmentEditorStub, + IsolatedDialog: { template: '
' }, + CloseableDialog: { + props: ['modelValue'], + template: + '
', + }, + SaveSegmentationDialog: { props: ['id'], template: '
' }, + VBtn: BtnStub, + VIcon: IconStub, + VSpacer: { template: '' }, + VTooltip: { template: '' }, + }, +}; + +const mountList = () => mount(SegmentList, { global: globalOptions }); + +const itemList = (wrapper: VueWrapper) => wrapper.findComponent(ItemListStub); + +const rowIds = (wrapper: VueWrapper) => + wrapper.findAll('.item-row').map((row) => row.attributes('data-id')); + +const rowButton = (wrapper: VueWrapper, id: string, icons: string[]) => { + const row = wrapper.find(`[data-id="${id}"]`); + if (!row.exists()) throw new Error(`No row for segment ${id}`); + const button = row + .findAll('button') + .find((candidate) => + icons.includes( + candidate.attributes('data-icon') || candidate.text().trim() + ) + ); + if (!button) throw new Error(`No ${icons.join('/')} button on row ${id}`); + return button; +}; + +const editor = (wrapper: VueWrapper) => + wrapper.findComponent(SegmentEditorStub); + +// Reveal carries its icon in the slot beside its tooltip, so the icon-name +// lookup the other row buttons use does not reach it. +const revealButton = (wrapper: VueWrapper, id: string) => { + const button = wrapper.find( + `[data-id="${id}"] [data-testid="reveal-segment-button"]` + ); + if (!button.exists()) throw new Error(`No reveal button on row ${id}`); + return button; +}; + +describe('flat segment list', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + it('lists the registry in creation order, keyed by type id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + + const wrapper = mountList(); + await nextTick(); + + expect(rowIds(wrapper)).toEqual([first.id, second.id]); + expect(itemList(wrapper).props('itemKey')).toBe('id'); + expect( + itemList(wrapper) + .props('items') + .map((item: { name: string }) => item.name) + ).toEqual(['Tumor', 'Node']); + }); + + it('lists a type that has no voxels yet', async () => { + const unbound = makeMask('img-1', 'Tumor'); + + const wrapper = mountList(); + await nextTick(); + + expect(unbound.record.representations.labelmap).toBeUndefined(); + expect(rowIds(wrapper)).toEqual([unbound.id]); + }); + + it('offers a type with no mask on this image', async () => { + const onTwo = makeMask('img-2', 'Node'); + const everywhere = makeSegment('Tumor'); + + const wrapper = mountList(); + await nextTick(); + + expect(rowIds(wrapper)).toEqual([onTwo.id, everywhere]); + expect(store().getSegmentationForImage('img-1')).toBeUndefined(); + }); + + it('keeps the same rows when the viewed image changes', async () => { + const onOne = makeMask('img-1', 'Tumor'); + const onTwo = makeMask('img-2', 'Node'); + + const wrapper = mountList(); + await nextTick(); + expect(rowIds(wrapper)).toEqual([onOne.id, onTwo.id]); + + await viewImage('img-2'); + + expect(rowIds(wrapper)).toEqual([onOne.id, onTwo.id]); + }); + + it('creates no segmentation for an image it renders', async () => { + const onOne = makeMask('img-1', 'Tumor'); + segments().selectSegment(onOne.segmentId); + await viewImage('img-2'); + + mountList(); + await nextTick(); + + expect(store().getSegmentationForImage('img-2')).toBeUndefined(); + // Rendering an empty list is not a deselection. + expect(segments().selectedSegmentId.value).toBe(onOne.segmentId); + }); + + it('leaves the selected type alone when it mounts', async () => { + const first = makeMask('img-1', 'Tumor'); + makeMask('img-1', 'Node'); + segments().selectSegment(first.segmentId); + + mountList(); + await nextTick(); + + expect(segments().selectedSegmentId.value).toBe(first.segmentId); + }); +}); + +describe('flat segment list with no viewed image', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + }); + + it('offers no list and no toggles until an image is viewed', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(wrapper.find('[data-testid="segment-list"]').exists()).toBe(false); + expect(wrapper.findAll('button')).toEqual([]); + expect(wrapper.text()).toContain('No selected image'); + }); + + it('renders the list once an image is viewed', async () => { + const wrapper = mountList(); + await nextTick(); + + await viewImage('img-1'); + + expect(wrapper.find('[data-testid="segment-list"]').exists()).toBe(true); + expect(wrapper.text()).not.toContain('No selected image'); + }); +}); + +describe('flat segment list selection', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + it('marks the selected type as the selected row', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + segments().selectSegment(second.segmentId); + + const wrapper = mountList(); + await nextTick(); + + expect(itemList(wrapper).props('modelValue')).toBe(second.segmentId); + }); + + it('selects a type by id when a row is picked', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + segments().selectSegment(first.segmentId); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('update:model-value', second.segmentId); + await nextTick(); + + expect(segments().selectedSegmentId.value).toBe(second.segmentId); + // Selecting creates nothing on any image. + expect(store().getSegmentationForImage('img-2')).toBeUndefined(); + }); + + it('keeps the selected row on an image the type has no mask on', async () => { + const onOne = makeMask('img-1', 'Tumor'); + segments().selectSegment(onOne.segmentId); + await viewImage('img-2'); + + const wrapper = mountList(); + await nextTick(); + + expect(itemList(wrapper).props('modelValue')).toBe(onOne.segmentId); + expect(store().getSegmentationForImage('img-2')).toBeUndefined(); + }); +}); + +describe('flat segment list row creation', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + it('adds a row without allocating any storage', async () => { + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(segments().segmentList.value).toHaveLength(1); + expect(store().getSegmentationForImage('img-1')).toBeUndefined(); + expect(boundMasks()).toEqual([]); + expect(rowIds(wrapper)).toEqual([segments().segmentList.value[0].id]); + }); + + it('selects the row it adds', async () => { + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(segments().selectedSegmentId.value).toBe( + segments().segmentList.value[0].id + ); + }); + + it('adds the row for every image at once', async () => { + await seatImage('img-2'); + makeMask('img-2', 'Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(rowIds(wrapper)).toHaveLength(2); + // The other image keeps the one mask it had; the new type has none. + expect(store().getSegmentationForImage('img-2')!.order).toHaveLength(1); + }); +}); + +describe('flat segment list row actions', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + it('toggles one segment’s visibility by id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + + await rowButton(wrapper, second.id, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + + expect(segments().appearanceOf(second.segmentId).visible).toBe(false); + expect(segments().appearanceOf(first.segmentId).visible).toBe(true); + }); + + it('toggles one segment’s lock by id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + + await rowButton(wrapper, second.id, ['mdi-lock', 'mdi-lock-open']).trigger( + 'click' + ); + + expect(segments().appearanceOf(second.segmentId).locked).toBe(true); + expect(segments().appearanceOf(first.segmentId).locked).toBe(false); + }); + + // The tooltip is the only place the panel can say what locking does, and the + // shared stub drops its content, so this mounts one that renders it. + const mountWithTooltips = () => + mount(SegmentList, { + global: { + stubs: { + ...globalOptions.stubs, + VTooltip: { template: '' }, + }, + }, + }); + + const lockTooltip = (wrapper: VueWrapper, id: string) => { + const button = wrapper + .find(`[data-id="${id}"]`) + .findAll('button') + .find((candidate) => + candidate + .findAll('i.icon') + .some((icon) => icon.text().trim().startsWith('mdi-lock')) + ); + if (!button) throw new Error(`No lock button on row ${id}`); + return button.find('.tooltip').text(); + }; + + it('says on the lock that it is what lets two segments share voxels', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mountWithTooltips(); + await nextTick(); + + expect(lockTooltip(wrapper, segment.id)).toMatch(/^Lock\b/); + expect(lockTooltip(wrapper, segment.id)).toMatch(/shares its voxels/i); + + lockSegment(segment.maskId, true); + await nextTick(); + + expect(lockTooltip(wrapper, segment.id)).toMatch(/^Unlock\b/); + expect(lockTooltip(wrapper, segment.id)).toMatch(/takes its voxels/i); + }); + + it('deletes one type, with the masks it had, by id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + + await rowButton(wrapper, first.id, ['mdi-delete']).trigger('click'); + await nextTick(); + + expect(store().getSegmentationForImage('img-1')!.order).toEqual([ + second.maskId, + ]); + expect(segments().getSegment(first.segmentId)).toBeUndefined(); + expect(rowIds(wrapper)).toEqual([second.id]); + }); + + it('offers visibility and lock on every row, mask here or not', async () => { + const withMask = makeMask('img-1', 'Tumor'); + const withoutMask = makeSegment('Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + // Both describe the type, so they hold on every image and are offered on + // a row this image has painted nothing for. + [withMask.id, withoutMask].forEach((id) => { + expect(rowButton(wrapper, id, ['mdi-eye', 'mdi-eye-off']).exists()).toBe( + true + ); + expect( + rowButton(wrapper, id, ['mdi-lock', 'mdi-lock-open']).exists() + ).toBe(true); + }); + + await rowButton(wrapper, withoutMask, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + + expect(segments().appearanceOf(withoutMask).visible).toBe(false); + }); + + it('hides every type at once, on every image', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + await seatImage('img-2'); + const elsewhere = makeMask('img-2', 'Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + await wrapper + .find('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + + expect(segments().appearanceOf(first.segmentId).visible).toBe(false); + expect(segments().appearanceOf(second.segmentId).visible).toBe(false); + expect(segments().appearanceOf(elsewhere.segmentId).visible).toBe(false); + }); +}); + +describe('flat segment list row editing', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + const openEditor = async (id: string) => { + const wrapper = mountList(); + await nextTick(); + await rowButton(wrapper, id, ['mdi-pencil']).trigger('click'); + await nextTick(); + return wrapper; + }; + + it('renames the row’s type by id, keeping that id', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = await openEditor(second.id); + + editor(wrapper).vm.$emit('update:name', 'Lesion'); + editor(wrapper).vm.$emit('done'); + await nextTick(); + + expect(segments().appearanceOf(second.segmentId).name).toBe('Lesion'); + expect(store().getMask(second.maskId).segmentId).toBe(second.segmentId); + }); + + it('recolors the row’s type by id', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + editor(wrapper).vm.$emit('update:color', '#0000ff'); + editor(wrapper).vm.$emit('done'); + await nextTick(); + + expect( + [...segments().appearanceOf(segment.segmentId).color].slice(0, 3) + ).toEqual([0, 0, 255]); + }); + + it('edits the type’s fill opacity, outline opacity and stroke width', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + expect(editor(wrapper).props('fillOpacity')).toBe(1); + expect(editor(wrapper).props('outlineOpacity')).toBe(1); + + editor(wrapper).vm.$emit('update:fillOpacity', 0.5); + editor(wrapper).vm.$emit('update:outlineOpacity', 0.25); + editor(wrapper).vm.$emit('update:strokeWidth', 3); + editor(wrapper).vm.$emit('done'); + await nextTick(); + + const appearance = segments().appearanceOf(segment.segmentId); + expect(appearance.fillOpacity).toBe(0.5); + expect(appearance.outlineOpacity).toBe(0.25); + expect(appearance.strokeWidth).toBe(3); + }); + + it('discards the edit when the dialog is cancelled', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + editor(wrapper).vm.$emit('update:name', 'Lesion'); + editor(wrapper).vm.$emit('update:fillOpacity', 0.5); + editor(wrapper).vm.$emit('cancel'); + await nextTick(); + + const appearance = segments().appearanceOf(segment.segmentId); + expect(appearance.name).toBe('Tumor'); + expect(appearance.fillOpacity).toBe(1); + }); + + it('offers the other rows’ names as taken', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = await openEditor(second.id); + + expect([...editor(wrapper).props('invalidNames')]).toEqual(['Tumor']); + }); + + it('passes the unedited name to the editor', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + expect(editor(wrapper).props('original')).toBe('Tumor'); + }); +}); + +// Cine annotations use registry identities without requiring voxel storage. +describe('flat segment list on a cine image', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + seatCineImage('cine-1'); + await viewImage('cine-1'); + }); + + it('creates and edits distinct measurement segments without allocating masks', async () => { + const wrapper = mountList(); + const rulers = useRulerStore(); + const placed = []; + for (const [name, color] of [ + ['Long axis', '#ff0000'], + ['Short axis', '#0000ff'], + ]) { + await wrapper.get('.create-row').trigger('click'); + const segmentId = segments().selectedSegmentId.value!; + await rowButton(wrapper, segmentId, ['mdi-pencil']).trigger('click'); + editor(wrapper).vm.$emit('update:name', name); + editor(wrapper).vm.$emit('update:color', color); + editor(wrapper).vm.$emit('done'); + await nextTick(); + const id = rulers.addTool({ + imageID: 'cine-1', + frame: 1, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + placing: true, + }); + rulers.placeTool(id); + placed.push(id); + } + + expect(segments().segmentList.value).toHaveLength(2); + expect(placed.map((id) => rulers.appearanceOfTool(id).name)).toEqual([ + 'Long axis', + 'Short axis', + ]); + expect( + placed.map((id) => [...rulers.appearanceOfTool(id).color].slice(0, 3)) + ).toEqual([ + [255, 0, 0], + [0, 0, 255], + ]); + expect( + new Set(placed.map((id) => rulers.toolByID[id].segmentId)).size + ).toBe(2); + expect(store().getSegmentationForImage('cine-1')).toBeUndefined(); + expect(boundMasks()).toEqual([]); + expect( + wrapper.get('[data-testid="save-segments-button"]').attributes('disabled') + ).toBeDefined(); + }); + + it.each([[1], [0, 1]])( + 'reveals an occupied cine frame for annotations on frames %j', + async (...frames) => { + const segmentId = segments().addSegment({ name: 'Measurement' }); + const rulers = useRulerStore(); + const ids = frames.map((frame) => + rulers.addTool({ + imageID: 'cine-1', + segmentId, + frame, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }) + ); + const playback = useCinePlaybackStore(); + const viewId = useViewStore().activeView!; + playback.updateConfig(viewId, 'cine-1', { + frame: frames[0] === 0 ? 1 : 0, + }); + const camera = useViewCameraStore(); + const pose = { + position: [4, 6, 10] as [number, number, number], + focalPoint: [4, 6, 0] as [number, number, number], + parallelScale: 25, + }; + camera.updateConfig(viewId, 'cine-1', pose); + const wrapper = mountList(); + + expect( + revealButton(wrapper, segmentId).attributes('disabled') + ).toBeUndefined(); + await revealButton(wrapper, segmentId).trigger('click'); + + expect(playback.getConfig(viewId, 'cine-1').frame).toBe(frames[0]); + expect(camera.getConfig(viewId, 'cine-1')).toMatchObject(pose); + // The shape action retains the same temporal navigation semantics. + rulers.jumpToTool(ids[ids.length - 1]); + expect(playback.getConfig(viewId, 'cine-1').frame).toBe( + frames[frames.length - 1] + ); + } + ); + + it('keeps an empty segment reveal disabled and leaves the frame alone', async () => { + const segmentId = segments().addSegment(); + const wrapper = mountList(); + const viewId = useViewStore().activeView!; + useCinePlaybackStore().updateConfig(viewId, 'cine-1', { frame: 1 }); + expect( + revealButton(wrapper, segmentId).attributes('disabled') + ).toBeDefined(); + await revealButton(wrapper, segmentId).trigger('click'); + expect(useCinePlaybackStore().getConfig(viewId, 'cine-1').frame).toBe(1); + }); +}); + +describe('segmentation display section', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + const slider = (wrapper: VueWrapper, label: string) => { + const found = wrapper + .findAllComponents(SliderStub) + .find((candidate) => candidate.props('label') === label); + if (!found) throw new Error(`No "${label}" slider`); + return found; + }; + + const setSlider = async ( + wrapper: VueWrapper, + label: string, + value: number + ) => { + slider(wrapper, label).vm.$emit('update:modelValue', value); + await nextTick(); + }; + + it('places display controls before the segment list', () => { + const wrapper = mountList(); + const sectionOrder = wrapper + .findAll('[data-testid$="-section"]') + .map((section) => section.attributes('data-testid')); + + expect(sectionOrder.indexOf('segment-display-section')).toBeLessThan( + sectionOrder.indexOf('segments-section') + ); + }); + + it('offers the default display controls before the image has a segmentation', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( + String(DEFAULT_SEGMENTATION_FILL_OPACITY) + ); + expect(slider(wrapper, 'Outline Opacity').attributes('data-value')).toBe( + '1' + ); + expect(slider(wrapper, 'Outline Thickness').attributes('data-value')).toBe( + '2' + ); + }); + + it('creates display state when a default control is changed', async () => { + const wrapper = mountList(); + + await setSlider(wrapper, 'Fill Opacity', 0.25); + + expect(store().getSegmentationForImage('img-1')?.fillOpacity).toBe(0.25); + }); + + it('seats each control at the segmentation’s current value', async () => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask( + segmentation.id, + segments().mintSegment({ name: 'Tumor' }) + ); + store().updateSegmentationDisplay(segmentation.id, { + fillOpacity: 0.4, + outlineOpacity: 0.6, + outlineThickness: 5, + }); + + const wrapper = mountList(); + await nextTick(); + + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( + '0.4' + ); + expect(slider(wrapper, 'Outline Opacity').attributes('data-value')).toBe( + '0.6' + ); + expect(slider(wrapper, 'Outline Thickness').attributes('data-value')).toBe( + '5' + ); + }); + + it.each([ + ['Fill Opacity', 'fillOpacity', 0.25], + ['Outline Opacity', 'outlineOpacity', 0.5], + ['Outline Thickness', 'outlineThickness', 4], + ] as const)( + 'writes %s onto the viewed image’s segmentation', + async (label, key, value) => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask( + segmentation.id, + segments().mintSegment({ name: 'Tumor' }) + ); + const wrapper = mountList(); + await nextTick(); + + await setSlider(wrapper, label, value); + + expect(store().getSegmentationForImage('img-1')![key]).toBe(value); + } + ); + + it('writes only the viewed image’s segmentation', async () => { + await seatImage('img-2', 'MR'); + const first = store().ensureSegmentationForImage('img-1'); + store().createMask(first.id, segments().mintSegment({ name: 'Tumor' })); + const second = store().ensureSegmentationForImage('img-2'); + store().createMask(second.id, segments().mintSegment({ name: 'Node' })); + const wrapper = mountList(); + await nextTick(); + + await setSlider(wrapper, 'Fill Opacity', 0.25); + + expect(store().getSegmentationForImage('img-1')!.fillOpacity).toBe(0.25); + expect(store().getSegmentationForImage('img-2')!.fillOpacity).toBe( + DEFAULT_SEGMENTATION_FILL_OPACITY + ); + }); +}); + +// Reveal Slice is the only row control that reads the viewed image's storage, +// so it is the one that has to say when this image holds nothing for the row. +describe('Reveal Slice on a segment row', () => { + const REVEAL_DIMENSIONS = [4, 4, 8] as const; + + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1', 'CT', REVEAL_DIMENSIONS); + await viewImage('img-1'); + }); + + const viewFor = (orientation: string) => { + const view = useViewStore() + .getAllViews() + .find( + (candidate) => + candidate.type === '2D' && + candidate.options.orientation === orientation + ); + if (!view) throw new Error(`No ${orientation} view`); + return view; + }; + + const sliceOn = (orientation: string) => + useViewSliceStore().getConfig(viewFor(orientation).id, 'img-1')!.slice; + + const setSliceOn = (orientation: string, slice: number) => + useViewSliceStore().updateConfig(viewFor(orientation).id, 'img-1', { + slice, + }); + + // Paint grows the allocation with padding and clips it to the volume, so the + // binding's extent is wider than what is marked and its middle is not the + // segment's. Marking through that same path is what keeps the reveal honest. + const STROKE_PADDING = 16; + + const paintVoxel = (maskId: string, index: Index3) => { + const voxels = store().maskVoxels(maskId); + voxels.materialize(); + const labelValue = SEGMENT_VALUE; + const [i, j, k] = index; + voxels.ensureContains([i, i, j, j, k, k], STROKE_PADDING); + const { extent } = voxels.binding()!; + const [mi, mj] = extentSize(extent); + voxels.scalars()[maskOffset({ extent, mi, mj }, i, j, k)] = labelValue; + voxels.image().modified(); + }; + + it('is offered disabled, saying why, on a row this image stores nothing for', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mountList(); + await nextTick(); + + expect( + revealButton(wrapper, segment.id).attributes('disabled') + ).toBeDefined(); + }); + + it('does not call a row empty while a load is still running', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mount(SegmentList, { + global: { + stubs: { + ...globalOptions.stubs, + VTooltip: { template: '' }, + }, + }, + }); + const list = () => wrapper.find('[data-testid="segment-list"]'); + const reason = () => + revealButton(wrapper, segment.id).element.parentElement?.textContent; + + useLoadDataStore().startLoading(); + await nextTick(); + expect(list().attributes('aria-busy')).toBe('true'); + expect(reason()).toMatch(/still loading/i); + + useLoadDataStore().stopLoading(); + await nextTick(); + expect(list().attributes('aria-busy')).toBe('false'); + expect(reason()).toMatch(/nothing on this image/i); + }); + + it('says on the disabled control that this image holds nothing for the row', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mount(SegmentList, { + global: { + stubs: { + ...globalOptions.stubs, + VTooltip: { template: '' }, + }, + }, + }); + await nextTick(); + + expect( + revealButton(wrapper, segment.id).element.parentElement?.textContent + ).toMatch(/nothing on this image/i); + }); + + it('puts each 2D view on the middle of what the segment marks here', async () => { + const segment = makeMask('img-1', 'Tumor'); + paintVoxel(segment.maskId, [1, 1, 6]); + const wrapper = mountList(); + await nextTick(); + + // The padded allocation spans the whole volume, so its own middle is the + // slice each view already shows. + expect(sliceOn('Axial')).toBe(4); + expect(sliceOn('Sagittal')).toBe(2); + + await revealButton(wrapper, segment.id).trigger('click'); + + expect(sliceOn('Axial')).toBe(6); + expect(sliceOn('Sagittal')).toBe(1); + expect(sliceOn('Coronal')).toBe(1); + }); + + it('moves outward from the center to the nearest occupied slice', async () => { + const segment = makeMask('img-1', 'Tumor'); + paintVoxel(segment.maskId, [1, 1, 1]); + paintVoxel(segment.maskId, [1, 1, 5]); + const wrapper = mountList(); + await nextTick(); + + await revealButton(wrapper, segment.id).trigger('click'); + + expect(sliceOn('Axial')).toBe(1); + }); + + it('enables reveal when painting creates storage after the list mounts', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mountList(); + await nextTick(); + expect( + revealButton(wrapper, segment.id).attributes('disabled') + ).toBeDefined(); + + paintVoxel(segment.maskId, [1, 1, 1]); + await nextTick(); + expect( + revealButton(wrapper, segment.id).attributes('disabled') + ).toBeUndefined(); + }); + + it('leaves the views where they are when the mask marks nothing', async () => { + const segment = makeMask('img-1', 'Tumor'); + paintVoxel(segment.maskId, [1, 1, 6]); + const voxels = store().maskVoxels(segment.maskId); + voxels.scalars().fill(0); + voxels.image().modified(); + const wrapper = mountList(); + await nextTick(); + setSliceOn('Axial', 7); + + await revealButton(wrapper, segment.id).trigger('click'); + + expect(sliceOn('Axial')).toBe(7); + }); +}); + +const ANNOTATION_STORES = [ + ['ruler', useRulerStore], + ['rectangle', useRectangleStore], + ['polygon', usePolygonStore], +] as const; + +describe.each(ANNOTATION_STORES)( + 'shared segment visibility for a %s', + (_name, useStore) => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + it('composes row and global visibility with independent child flags across images and cine frames', async () => { + const tools = useStore(); + const segmentId = segments().addSegment(); + const addShape = (imageID: string, hidden = false, frame?: number) => + tools.addTool({ + imageID, + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + hidden, + frame, + }); + const shown = addShape('img-1'); + const hidden = addShape('img-1', true); + seatCineImage('cine-1'); + const cineFirst = addShape('cine-1', false, 0); + const cineSecond = addShape('cine-1', false, 1); + const viewFrame = ref(); + const rendered = useCurrentTools(tools, ref('Axial'), ref([]), viewFrame); + const ids = () => rendered.value.map((tool) => tool.id); + const wrapper = mountList(); + expect(ids()).toEqual([shown]); + + await rowButton(wrapper, segmentId, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + expect(ids()).toEqual([]); + await viewImage('cine-1'); + viewFrame.value = 0; + expect(ids()).toEqual([]); + await wrapper + .get('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + expect(ids()).toEqual([cineFirst]); + viewFrame.value = 1; + expect(ids()).toEqual([cineSecond]); + await wrapper + .get('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + expect(ids()).toEqual([]); + await rowButton(wrapper, segmentId, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + await viewImage('img-1'); + viewFrame.value = undefined; + expect(ids()).toEqual([shown]); + expect(tools.toolByID[hidden].hidden).toBe(true); + }); + + it('keeps the active placement alive through hiding, committing and starting again', () => { + const tools = useStore(); + const segmentId = segments().addSegment(); + const metadata = ref({ + imageID: 'img-1', + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + const placing = usePlacingAnnotationTool(tools, metadata); + placing.add(); + const first = placing.id.value!; + const whitelist = ref([first]); + const rendered = useCurrentTools(tools, ref('Axial'), whitelist); + const otherViewStub = tools.addTool({ ...metadata.value, placing: true }); + placing.beginPlacement(); + segments().updateSegment(segmentId, { visible: false }); + expect(rendered.value.map((tool) => tool.id)).toEqual([first]); + expect(tools.toolByID[otherViewStub]).toBeDefined(); + + placing.commit(); + expect(rendered.value).toEqual([]); + expect(tools.toolByID[first].placing).toBe(false); + placing.add(); + whitelist.value = [placing.id.value!]; + expect(rendered.value.map((tool) => tool.id)).toEqual([placing.id.value]); + segments().updateSegment(segmentId, { visible: true }); + expect(rendered.value.map((tool) => tool.id)).toEqual([ + first, + placing.id.value, + ]); + placing.remove(); + expect(rendered.value.map((tool) => tool.id)).toEqual([first]); + }); + } +); + +describe('locked segment editor routes', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + const protectedContent = () => { + const segmentId = segments().addSegment({ name: 'Tumor' }); + const rulers = useRulerStore(); + const content = ['img-1', 'img-2'].map((imageID) => { + const mask = maskOn(imageID, segmentId); + seedVoxel(mask.id, [1, 1, 0]); + const ruler = rulers.addTool({ + imageID, + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + return { imageID, maskId: mask.id, ruler }; + }); + const expectPreserved = () => { + expect(segments().appearanceOf(segmentId).name).toBe('Tumor'); + content.forEach(({ imageID, maskId, ruler }) => { + expect(store().maskFor(imageID, segmentId)?.id).toBe(maskId); + expect(markedVoxels(maskId)).toEqual([[1, 1, 0, SEGMENT_VALUE]]); + expect(rulers.toolByID[ruler].segmentId).toBe(segmentId); + }); + }; + return { segmentId, expectPreserved }; + }; + + it('disables color, edit and delete consistently and restores editing after unlocking', async () => { + const { segmentId, expectPreserved } = protectedContent(); + segments().updateSegment(segmentId, { locked: true }); + const wrapper = mountList(); + for (const action of [ + 'segment-color-button', + 'edit-segment-button', + 'delete-segment-button', + ]) { + const button = wrapper.get( + `[data-id="${segmentId}"] [data-testid="${action}"]` + ); + expect(button.attributes('disabled')).toBeDefined(); + await button.trigger('click'); + expect(editor(wrapper).exists()).toBe(false); + expectPreserved(); + } + segments().updateSegment(segmentId, { locked: false }); + await nextTick(); + await wrapper.get('[data-testid="segment-color-button"]').trigger('click'); + expect(editor(wrapper).exists()).toBe(true); + editor(wrapper).vm.$emit('update:name', 'Lesion'); + editor(wrapper).vm.$emit('done'); + await nextTick(); + expect(segments().appearanceOf(segmentId).name).toBe('Lesion'); + }); + + it.each(['done', 'delete'])( + 'refuses %s if the segment becomes locked while its editor is open', + async (action) => { + const { segmentId, expectPreserved } = protectedContent(); + const wrapper = mountList(); + await wrapper + .get('[data-testid="segment-color-button"]') + .trigger('click'); + editor(wrapper).vm.$emit('update:name', 'Changed'); + await nextTick(); + segments().updateSegment(segmentId, { locked: true }); + await nextTick(); + expect(editor(wrapper).props('locked')).toBe(true); + editor(wrapper).vm.$emit(action); + await nextTick(); + expectPreserved(); + } + ); +}); + +// --------------------------------------------------------------------------- +// Deleting a segment cascades to its mask on every image and to every +// annotation naming it, none of which need be visible here, and there is no +// undo. No dialog asks first, as everywhere else in the app, so the list says +// afterwards what went, the way removeSelectedTools does. +// --------------------------------------------------------------------------- + +describe('deleting a segment says what went with it', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + const titles = () => + useMessageStore().messages.map((message) => message.title); + + const spreadSegment = (imageIDs: string[], name = 'Tumor') => { + const segmentId = segments().addSegment({ name }); + const rulers = useRulerStore(); + imageIDs.forEach((imageID) => { + const mask = maskOn(imageID, segmentId); + seedVoxel(mask.id, [1, 1, 0]); + rulers.addTool({ + imageID, + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + }); + return segmentId; + }; + + const deleteRow = async (wrapper: VueWrapper, id: string) => { + await rowButton(wrapper, id, ['mdi-delete']).trigger('click'); + await nextTick(); + }; + + it('counts the masks, the images they were on, and the annotations', async () => { + const segmentId = spreadSegment(['img-1', 'img-2']); + const wrapper = mountList(); + await nextTick(); + + await deleteRow(wrapper, segmentId); + + expect(titles()).toEqual(['Deleted 2 masks on 2 images and 2 annotations']); + }); + + it('says one of each in the singular', async () => { + const segmentId = spreadSegment(['img-2']); + const wrapper = mountList(); + await nextTick(); + + await deleteRow(wrapper, segmentId); + + expect(titles()).toEqual(['Deleted 1 mask on 1 image and 1 annotation']); + }); + + it('names only what the segment had', async () => { + const painted = makeMask('img-1', 'Painted'); + seedVoxel(painted.maskId, [1, 1, 0]); + const shaped = segments().addSegment({ name: 'Shaped' }); + useRulerStore().addTool({ + imageID: 'img-1', + segmentId: shaped, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + const wrapper = mountList(); + await nextTick(); + + await deleteRow(wrapper, painted.id); + await deleteRow(wrapper, shaped); + + expect(titles()).toEqual([ + 'Deleted 1 mask on 1 image', + 'Deleted 1 annotation', + ]); + }); + + // A record is minted the moment a segment is resolved as an edit target, so + // an image can hold one for a segment that was never painted there. Deleting + // drops the record, but there was nothing on that image to lose. + it('counts no mask on an image the segment was only resolved on', async () => { + const recorded = makeMask('img-1', 'Resolved'); + const allocated = maskOn('img-2', recorded.id); + store().maskVoxels(allocated.id).materialize(); + const wrapper = mountList(); + await nextTick(); + + await deleteRow(wrapper, recorded.id); + + expect(segments().getSegment(recorded.id)).toBeUndefined(); + expect(titles()).toEqual([]); + }); + + it('stays quiet when the segment held nothing', async () => { + const empty = makeSegment('Empty'); + const wrapper = mountList(); + await nextTick(); + + await deleteRow(wrapper, empty); + + expect(segments().getSegment(empty)).toBeUndefined(); + expect(titles()).toEqual([]); + }); + + it('reports the same cascade when the editor deletes', async () => { + const segmentId = spreadSegment(['img-1', 'img-2']); + const wrapper = mountList(); + await nextTick(); + await wrapper + .get(`[data-id="${segmentId}"] [data-testid="segment-color-button"]`) + .trigger('click'); + + editor(wrapper).vm.$emit('delete'); + await nextTick(); + + expect(segments().getSegment(segmentId)).toBeUndefined(); + expect(titles()).toEqual(['Deleted 2 masks on 2 images and 2 annotations']); + }); +}); + +// --------------------------------------------------------------------------- +// A row is rebuilt from every annotation in the scene, and dragging one ruler +// is a store write per pointer move. The list hands back the row object it +// built last time when nothing the row shows has changed, so the item list's +// per-row memo holds and only the rows that changed re-render. +// --------------------------------------------------------------------------- + +describe('segment row identity', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + const rulerOn = (segmentId: string, slice = 0) => + useRulerStore().addTool({ + imageID: 'img-1', + segmentId, + slice, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + + const rowsOf = (wrapper: VueWrapper) => + itemList(wrapper).props('items') as Array<{ id: string }>; + + it('keeps every row when an annotation moves', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const ruler = rulerOn(first.segmentId); + const wrapper = mountList(); + await nextTick(); + const before = rowsOf(wrapper); + + useRulerStore().updateTool(ruler, { slice: 1 }); + await nextTick(); + + const after = rowsOf(wrapper); + expect(useRulerStore().toolByID[ruler].slice).toBe(1); + expect(after[0]).toBe(before[0]); + expect(after[1]).toBe(before[1]); + expect(after.map((row) => row.id)).toEqual([first.id, second.id]); + }); + + it('replaces only the row whose annotation count changed', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + const before = rowsOf(wrapper); + + rulerOn(second.segmentId); + await nextTick(); + + const after = rowsOf(wrapper); + expect(after[0]).toBe(before[0]); + expect(after[1]).not.toBe(before[1]); + }); + + it('replaces only the row whose own fields changed', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + const before = rowsOf(wrapper); + + segments().updateSegment(second.segmentId, { name: 'Lesion' }); + await nextTick(); + + const after = rowsOf(wrapper); + expect(after[0]).toBe(before[0]); + expect(after[1]).not.toBe(before[1]); + }); + + it('still offers reveal for a segment that only has annotations', async () => { + const shaped = makeSegment('Shaped'); + rulerOn(shaped); + const wrapper = mountList(); + await nextTick(); + + expect( + revealButton(wrapper, shaped).attributes('disabled') + ).toBeUndefined(); + }); +}); diff --git a/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts b/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts new file mode 100644 index 000000000..3d551ac59 --- /dev/null +++ b/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts @@ -0,0 +1,328 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { repoRoot } from '@/src/__tests__/sourceAudit'; +import { setActivePinia, createPinia } from 'pinia'; +import { defineComponent, nextTick } from 'vue'; +import { mount, VueWrapper } from '@vue/test-utils'; + +import SegmentList from '@/src/segmentation/components/SegmentList.vue'; +import { + seatSpecImage as seatImage, + store, + mintSegment, + seedVoxel, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useViewStore } from '@/src/store/views'; + +// --------------------------------------------------------------------------- +// The segmentation panel is one flat list scoped to the viewed image. Saving +// that image's segmentation to a file lives on the list, and is absent when the +// image has nothing to save. +// +// No panel says "segment group", "labelmap", "label value" or "layer" in +// user-visible text. Identifiers are out of scope, so the scan reads text nodes +// and the static attributes a user actually reads, never template expressions +// or component names. +// --------------------------------------------------------------------------- + +const viewImage = async (id: string) => { + useViewStore().setDataForAllViews(id); + await nextTick(); +}; + +const ItemListStub = defineComponent({ + name: 'EditableItemList', + props: ['items', 'itemKey', 'itemTitle', 'modelValue', 'createText'], + emits: ['update:model-value', 'create'], + template: ` +
+
+ + +
+
+ `, +}); + +const BtnStub = defineComponent({ + name: 'VBtn', + props: ['icon', 'disabled'], + template: ``, +}); + +const SaveDialogStub = defineComponent({ + name: 'SaveSegmentationDialog', + props: ['id'], + emits: ['done'], + template: `
`, +}); + +// Either dialog host works: the slot renders unless the host is explicitly +// closed, so a `v-model`-gated host and an inner `v-if` both read correctly. +const DialogHostStub = (name: string) => + defineComponent({ + name, + props: ['modelValue', 'maxWidth'], + emits: ['update:modelValue'], + template: `
`, + }); + +const globalOptions = { + stubs: { + EditableItemList: ItemListStub, + SegmentEditor: { template: '
' }, + SaveSegmentationDialog: SaveDialogStub, + IsolatedDialog: DialogHostStub('IsolatedDialog'), + CloseableDialog: DialogHostStub('CloseableDialog'), + VDialog: DialogHostStub('VDialog'), + VBtn: BtnStub, + VIcon: { template: '' }, + VTooltip: { template: '' }, + VMenu: { + template: '
', + }, + VList: { template: '
' }, + VListItem: { template: '
' }, + VSpacer: { template: '' }, + VSlider: { props: ['label', 'modelValue'], template: '' }, + VExpansionPanels: { template: '
' }, + VExpansionPanel: { template: '
' }, + VExpansionPanelTitle: { template: '' }, + VExpansionPanelText: { template: '
' }, + VDivider: { template: '
' }, + }, +}; + +const mountList = () => + mount(SegmentList, { + props: { + registry: useSegmentStore().segments, + noun: 'segment', + masked: true, + }, + global: globalOptions, + }); + +const saveButton = (wrapper: VueWrapper) => + wrapper.find('[data-testid="save-segments-button"]'); + +const saveDialog = (wrapper: VueWrapper) => + wrapper.findComponent(SaveDialogStub); + +const paintMask = (imageId: string, name: string) => { + const segmentation = store().ensureSegmentationForImage(imageId); + const mask = store().createMask(segmentation.id, mintSegment({ name })); + seedVoxel(mask.id, [1, 1, 0]); + return segmentation; +}; + +describe('saving from the flat segment panel', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2', 'MR'); + await viewImage('img-1'); + }); + + it('offers the save affordance disabled, saying why, until something is painted', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(saveButton(wrapper).exists()).toBe(true); + expect(saveButton(wrapper).attributes('disabled')).toBeDefined(); + expect(wrapper.text()).toContain('Nothing is painted on this image yet'); + }); + + it('offers one save affordance once the viewed image has segments', async () => { + paintMask('img-1', 'Tumor'); + const wrapper = mountList(); + await nextTick(); + + expect( + wrapper.findAll('[data-testid="save-segments-button"]') + ).toHaveLength(1); + }); + + // A segment resolved as an edit target mints a record, and allocating its + // storage does not put a voxel in it: neither is anything to write out. + it('keeps the save affordance disabled for masks that hold nothing', async () => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask(segmentation.id, mintSegment({ name: 'Resolved' })); + const allocated = store().createMask( + segmentation.id, + mintSegment({ name: 'Allocated' }) + ); + store().maskVoxels(allocated.id).materialize(); + const wrapper = mountList(); + await nextTick(); + + expect(saveButton(wrapper).attributes('disabled')).toBeDefined(); + expect(wrapper.text()).toContain('Nothing is painted on this image yet'); + }); + + it('opens the save dialog on the viewed image segmentation', async () => { + const segmentation = paintMask('img-1', 'Tumor'); + const wrapper = mountList(); + await nextTick(); + + expect(saveDialog(wrapper).exists()).toBe(false); + expect(saveButton(wrapper).exists()).toBe(true); + + await saveButton(wrapper).trigger('click'); + await nextTick(); + + expect(saveDialog(wrapper).props('id')).toBe(segmentation.id); + }); + + // The create affordance names the row it adds, and it reads as an expression + // rather than a literal attribute, so the source scan below cannot see it. + it('names what the create affordance adds without a storage word', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(wrapper.findComponent(ItemListStub).props('createText')).toBe( + 'New segment' + ); + }); + + it('follows the viewed image rather than the selected type', async () => { + const first = paintMask('img-1', 'Tumor'); + const second = store().ensureSegmentationForImage('img-2'); + const onSecond = store().createMask( + second.id, + mintSegment({ name: 'Node' }) + ); + // The selected type has its mask on the image that is NOT being viewed. + useSegmentStore().segments.selectSegment(onSecond.segmentId); + const wrapper = mountList(); + await nextTick(); + + expect(saveButton(wrapper).exists()).toBe(true); + await saveButton(wrapper).trigger('click'); + await nextTick(); + + expect(saveDialog(wrapper).props('id')).toBe(first.id); + }); +}); + +// --- user-visible panel text --- // + +const exists = (rel: string) => fs.existsSync(path.resolve(repoRoot, rel)); +const read = (rel: string) => + fs.readFileSync(path.resolve(repoRoot, rel), 'utf-8'); + +const VISIBLE_ATTRIBUTES = [ + 'label', + 'title', + 'placeholder', + 'hint', + 'text', + 'subtitle', + 'aria-label', + 'create-text', +]; + +/** + * The literal text a single-file component puts on screen: static text nodes + * plus unbound user-facing attributes. Script, style, comments, tags, + * attribute-bound expressions and `{{ }}` interpolations are all dropped, so an + * internal identifier never counts as panel language. + */ +function visibleText(source: string) { + const markup = source + .replace(//g, ' ') + .replace(//g, ' ') + .replace(//g, ' '); + + const attributes = VISIBLE_ATTRIBUTES.flatMap((name) => + [...markup.matchAll(new RegExp(`(^|\\s)${name}="([^"]*)"`, 'g'))].map( + (match) => match[2] + ) + ); + + const text = markup + .replace(/\{\{[\s\S]*?\}\}/g, ' ') + .replace(/<[^>]*>/g, ' '); + + return [...attributes, text].join('\n'); +} + +const bannedIn = (rel: string, banned: RegExp) => + visibleText(read(rel)) + .split('\n') + .map((line) => line.trim()) + .filter((line) => banned.test(line)); + +/** + * Notification and error titles are panel language too, and they live in the + * script block where `visibleText` cannot see them. + */ +const MESSAGE_CALL = + /(?:useErrorMessage|addError|addWarning|addSuccess|new Error)\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/g; + +const bannedMessagesIn = (rel: string, banned: RegExp) => + [...read(rel).matchAll(MESSAGE_CALL)] + .map((match) => match[2]) + .filter((message) => banned.test(message)); + +const SEGMENTATION_PANEL = [ + 'src/components/AnnotationsModule.vue', + 'src/segmentation/components/SegmentList.vue', + 'src/segmentation/components/SegmentEditor.vue', + 'src/segmentation/components/PaintControls.vue', + 'src/segmentation/components/SaveSegmentationDialog.vue', +]; + +const componentFiles = (dir: string): string[] => + fs + .readdirSync(path.resolve(repoRoot, dir), { withFileTypes: true }) + .flatMap((entry) => { + const rel = path.posix.join(dir, entry.name); + if (entry.isDirectory()) return componentFiles(rel); + return entry.name.endsWith('.vue') ? [rel] : []; + }); + +describe('panel language', () => { + it('keeps the segmentation panel free of group and storage words', () => { + // These two must be present, so the scan is never vacuous; a renamed save + // dialog simply drops out of the list. + expect(exists('src/components/AnnotationsModule.vue')).toBe(true); + expect(exists('src/segmentation/components/SegmentList.vue')).toBe(true); + + const banned = /segment group|labelmap|label value|layer/i; + const hits = SEGMENTATION_PANEL.filter(exists).flatMap((rel) => + bannedIn(rel, banned).map((line) => `${rel}: ${line}`) + ); + + expect(hits).toEqual([]); + }); + + it('keeps the panel’s notification titles free of storage words', () => { + const files = SEGMENTATION_PANEL.filter(exists); + // The panel reports at least one failure to the user, so the scan reads + // something rather than passing on an empty match set. + const messages = files.flatMap((rel) => bannedMessagesIn(rel, /.*/)); + expect(messages.length).toBeGreaterThan(0); + + const banned = /segment group|labelmap|label value/i; + const hits = files.flatMap((rel) => + bannedMessagesIn(rel, banned).map((message) => `${rel}: ${message}`) + ); + + expect(hits).toEqual([]); + }); + + it('says "segment group" nowhere a user can read it', () => { + // "Layer" is a separate VolView feature and keeps its name; the storage + // words do not survive anywhere in the component tree. + const banned = /segment group|labelmap|label value/i; + const hits = [ + ...componentFiles('src/components'), + ...componentFiles('src/segmentation'), + ].flatMap((rel) => bannedIn(rel, banned).map((line) => `${rel}: ${line}`)); + + expect(hits).toEqual([]); + }); +}); diff --git a/src/segmentation/composables/deleteSegment.ts b/src/segmentation/composables/deleteSegment.ts new file mode 100644 index 000000000..ee8803240 --- /dev/null +++ b/src/segmentation/composables/deleteSegment.ts @@ -0,0 +1,54 @@ +import { maskHasContent } from '@/src/segmentation/model'; +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useMessageStore } from '@/src/store/messages'; +import { AnnotationToolStoreMap } from '@/src/store/tools'; +import { plural } from '@/src/utils'; + +/** + * What deleting a segment is about to take with it, counted before the cascade + * runs: its mask on every image, and every finished annotation naming it. A + * tool still being placed is not counted, because the cascade leaves it alone, + * and neither is a mask record with nothing in it: the cascade drops the + * record, but the user never put anything on that image to lose. + */ +function countCascade(segmentId: string) { + const images = Object.values(useSegmentationStore().segmentations).flatMap( + (segmentation) => + Object.values(segmentation.masks) + .filter((mask) => mask.segmentId === segmentId && maskHasContent(mask)) + .map(() => segmentation.parentImageId) + ); + const annotations = Object.values(AnnotationToolStoreMap).reduce( + (total, useStore) => + total + + useStore().finishedTools.filter((tool) => tool.segmentId === segmentId) + .length, + 0 + ); + return { masks: images.length, images: new Set(images).size, annotations }; +} + +/** + * Deletes a segment and says what went with it. The cascade reaches masks on + * images this one is not viewing and annotations on other slices and axes, so + * its scope is invisible from here and there is no undo: the same reason + * `removeSelectedTools` reports its count. No dialog asks first, which is what + * the rest of the app does. + */ +export function deleteSegmentAndReport( + registry: SegmentRegistry, + segmentId: string +) { + if (!registry.getSegment(segmentId)) return; + const { masks, images, annotations } = countCascade(segmentId); + registry.deleteSegment(segmentId); + + const removed = [ + masks > 0 && + `${masks} ${plural(masks, 'mask')} on ${images} ${plural(images, 'image')}`, + annotations > 0 && `${annotations} ${plural(annotations, 'annotation')}`, + ].filter((part): part is string => !!part); + if (removed.length > 0) + useMessageStore().addInfo(`Deleted ${removed.join(' and ')}`); +} diff --git a/src/segmentation/composables/useMaskRevision.ts b/src/segmentation/composables/useMaskRevision.ts new file mode 100644 index 000000000..ba0a4db3b --- /dev/null +++ b/src/segmentation/composables/useMaskRevision.ts @@ -0,0 +1,35 @@ +import { ref, watchEffect } from 'vue'; + +import { useSegmentationStore } from '@/src/segmentation/store'; +import { listMasks } from '@/src/segmentation/model'; + +/** + * A counter every mask change bumps, voxel writes included. A mask's extent is + * reactive but a write inside the box it already has moves nothing, so this is + * the only trace of one a consumer can watch. It says something changed and + * nothing about what. A stroke bumps it once per changed mask per sample, + * so debounce anything expensive that reads it. + * + * Scoped to the caller: the masks are watched only while it is alive. + */ +export function useMaskRevision() { + const segmentationStore = useSegmentationStore(); + const revision = ref(0); + + // Re-taken whenever a mask is seated or dropped. Every writer already + // announces itself to vtk, so watching the mask itself catches the ones that + // reach the buffer without going through the store. + watchEffect((onCleanup) => { + const subscriptions = Object.values(segmentationStore.segmentations) + .flatMap((segmentation) => listMasks(segmentation)) + .flatMap((segment) => segment.representations.labelmap ?? []) + .map((binding) => + binding.image.onModified(() => { + revision.value += 1; + }) + ); + onCleanup(() => subscriptions.forEach((entry) => entry.unsubscribe())); + }); + + return revision; +} diff --git a/src/segmentation/composables/usePaintInteractionMode.ts b/src/segmentation/composables/usePaintInteractionMode.ts new file mode 100644 index 000000000..9b1cd7345 --- /dev/null +++ b/src/segmentation/composables/usePaintInteractionMode.ts @@ -0,0 +1,14 @@ +import { computed } from 'vue'; +import { PaintMode } from '@/src/core/tools/paint'; +import { usePaintToolStore } from '@/src/store/tools/paint'; +import { useActionHeld } from '@/src/composables/useKeyboardShortcuts'; + +export function usePaintInteractionMode() { + const paint = usePaintToolStore(); + const held = useActionHeld('paintEyedropper'); + return computed(() => + paint.isActive && paint.isPaintingModeActive && held.value + ? PaintMode.Eyedropper + : paint.activePaintMode + ); +} diff --git a/src/segmentation/composables/useSegmentEditing.ts b/src/segmentation/composables/useSegmentEditing.ts new file mode 100644 index 000000000..14b603f50 --- /dev/null +++ b/src/segmentation/composables/useSegmentEditing.ts @@ -0,0 +1,100 @@ +import { computed, reactive, ref } from 'vue'; + +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import type { Maybe } from '@/src/types'; +import { cssColorToRGBA } from '@/src/segmentation/color'; +import { deleteSegmentAndReport } from '@/src/segmentation/composables/deleteSegment'; + +/** + * The edit dialog both pickers open: one editor, one set of fields, one place + * that decides what a name may be. Reads every field through the registry's + * resolver, so an unset one shows the app default. + */ +export function useSegmentEditing(registry: () => SegmentRegistry) { + const editingSegmentId = ref>(undefined); + const editDialog = ref(false); + const editState = reactive({ + name: '', + color: '', + fillOpacity: 1, + outlineOpacity: 1, + strokeWidth: 1, + }); + + const editingSegment = computed(() => + editingSegmentId.value + ? registry().getSegment(editingSegmentId.value) + : undefined + ); + + const editingName = computed( + () => registry().appearanceOf(editingSegmentId.value).name + ); + + const invalidNames = computed( + () => + new Set( + registry() + .segmentList.value.filter( + (type) => type.id !== editingSegmentId.value + ) + .map((type) => registry().appearanceOf(type.id).name.trim()) + ) + ); + + function startEditing(id: string) { + if (!registry().getSegment(id) || registry().appearanceOf(id).locked) + return; + const appearance = registry().appearanceOf(id); + editingSegmentId.value = id; + editDialog.value = true; + editState.name = appearance.name; + editState.color = appearance.cssColor; + editState.fillOpacity = appearance.fillOpacity; + editState.outlineOpacity = appearance.outlineOpacity; + editState.strokeWidth = appearance.strokeWidth; + } + + function stopEditing(commit: boolean) { + const id = editingSegmentId.value; + if ( + id && + commit && + registry().getSegment(id) && + !registry().appearanceOf(id).locked + ) { + registry().updateSegment(id, { + name: editState.name.trim(), + color: cssColorToRGBA(editState.color), + fillOpacity: editState.fillOpacity, + outlineOpacity: editState.outlineOpacity, + strokeWidth: editState.strokeWidth, + }); + } + editingSegmentId.value = undefined; + editDialog.value = false; + } + + // Deleting a segment takes its masks on every image and its shapes with it. + function deleteEditingSegment() { + const id = editingSegmentId.value; + if (id && !registry().appearanceOf(id).locked) + deleteSegmentAndReport(registry(), id); + stopEditing(false); + } + + return { + editingSegmentId, + editDialog, + editState, + editingSegment, + editingName, + editingLocked: computed( + () => registry().appearanceOf(editingSegmentId.value).locked + ), + invalidNames, + startEditing, + stopEditing, + deleteEditingSegment, + }; +} diff --git a/src/segmentation/composables/useSegmentRevealPulse.ts b/src/segmentation/composables/useSegmentRevealPulse.ts new file mode 100644 index 000000000..a0f3df905 --- /dev/null +++ b/src/segmentation/composables/useSegmentRevealPulse.ts @@ -0,0 +1,30 @@ +import { computed, reactive, unref, type MaybeRef } from 'vue'; + +const pulseByMaskId = reactive>({}); +const animationByMaskId = new Map(); + +export const revealPulseStrength = (maskId: MaybeRef) => + computed(() => pulseByMaskId[unref(maskId)] ?? 0); + +export function pulseSegmentMask(maskId: string) { + const previous = animationByMaskId.get(maskId); + if (previous != null) cancelAnimationFrame(previous); + + const started = performance.now(); + const duration = 3000; + const pulsePeriod = 500; + const animate = (now: number) => { + const elapsed = now - started; + if (elapsed < duration) { + pulseByMaskId[maskId] = Math.abs( + Math.sin((Math.PI * elapsed) / pulsePeriod) + ); + animationByMaskId.set(maskId, requestAnimationFrame(animate)); + return; + } + delete pulseByMaskId[maskId]; + animationByMaskId.delete(maskId); + }; + + animationByMaskId.set(maskId, requestAnimationFrame(animate)); +} diff --git a/src/segmentation/composables/useSegmentShapes.ts b/src/segmentation/composables/useSegmentShapes.ts new file mode 100644 index 000000000..0ea2e5a2f --- /dev/null +++ b/src/segmentation/composables/useSegmentShapes.ts @@ -0,0 +1,93 @@ +import { computed } from 'vue'; + +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { useAnnotationToolStore } from '@/src/store/tools'; +import { AnnotationToolType } from '@/src/store/tools/types'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { frameOfReferenceToImageSliceAndAxis } from '@/src/utils/frameOfReference'; +import type { AnnotationTool } from '@/src/types/annotation-tool'; + +const SHAPE_TOOLS = [ + { type: AnnotationToolType.Ruler, icon: 'mdi-ruler' }, + { type: AnnotationToolType.Rectangle, icon: 'mdi-vector-square' }, + { type: AnnotationToolType.Polygon, icon: 'mdi-pentagon-outline' }, +]; + +const placement = (tool: AnnotationTool & { axis: string }) => + tool.frame != null + ? `Frame ${tool.frame + 1}` + : `${tool.axis} ${tool.slice + 1}`; + +/** + * The shapes drawn on the viewed image, grouped by the segment each one names. + * A segment's row lists these under it, so the sidebar holds no second list of + * the same annotations. + */ +export function useSegmentShapes() { + const { currentImageID, currentImageMetadata } = useCurrentImage(); + + const shapes = computed(() => + SHAPE_TOOLS.flatMap(({ type, icon }) => { + const store = useAnnotationToolStore(type); + const rulers = useRulerStore(); + return store.finishedTools + .filter((tool) => tool.imageID === currentImageID.value) + .map((tool) => { + const { axis } = frameOfReferenceToImageSliceAndAxis( + tool.frameOfReference, + currentImageMetadata.value, + { allowOutOfBoundsSlice: true } + ) ?? { axis: 'unknown' }; + const located = { ...tool, axis }; + return { + id: tool.id, + type, + icon, + segmentId: tool.segmentId, + hidden: !!tool.hidden, + axis, + slice: tool.slice, + frame: tool.frame, + placement: placement(located), + // Only a ruler carries a number a user reads off the list. + measurement: + type === AnnotationToolType.Ruler + ? `${rulers.lengthByID[tool.id].toFixed(2)}mm` + : '', + jumpTo: () => store.jumpToTool(tool.id), + remove: () => store.removeTool(tool.id), + toggleHidden: () => + store.updateTool(tool.id, { + hidden: !store.toolByID[tool.id].hidden, + }), + setHidden: (hidden: boolean) => + store.updateTool(tool.id, { hidden }), + assignSegment: (segmentId: string) => + store.updateTool(tool.id, { segmentId }), + }; + }); + }) + ); + + // Grouped once so a list of segments costs one pass over the shapes rather + // than one pass per segment. + const shapesBySegment = computed(() => { + const bySegment = new Map(); + shapes.value.forEach((shape) => { + if (!shape.segmentId) return; + const group = bySegment.get(shape.segmentId); + if (group) group.push(shape); + else bySegment.set(shape.segmentId, [shape]); + }); + return bySegment; + }); + + const shapesOf = (segmentId: string) => + shapesBySegment.value.get(segmentId) ?? []; + + return { shapes, shapesOf }; +} + +export type SegmentShape = ReturnType< + typeof useSegmentShapes +>['shapes']['value'][number]; diff --git a/src/segmentation/editing/__tests__/processWorker.spec.ts b/src/segmentation/editing/__tests__/processWorker.spec.ts new file mode 100644 index 000000000..95e1a4abd --- /dev/null +++ b/src/segmentation/editing/__tests__/processWorker.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { terminateProcessWorkers } from '@/src/segmentation/editing/processWorker'; +import { hostOverSilentWorkers } from '@/src/segmentation/editing/__tests__/silentWorker'; + +// --------------------------------------------------------------------------- +// A process worker that dies, and one a cancelled run walks away from. Comlink +// answers a call only when the worker posts a reply, so a worker that fails to +// load its module chunk, or that the browser kills, leaves the call waiting +// forever: the process stays in `computing`, nothing is rolled back, and the +// cached instance poisons every later run. A job already posted cannot be +// called back either, so a cancelled run's work would keep the worker busy. +// --------------------------------------------------------------------------- + +describe('a process worker host', () => { + it('reuses one worker across calls', async () => { + const { host, workers } = hostOverSilentWorkers(); + + // Discarded results get a catch: a call the host later ends rejects, and + // a promise nobody holds would report that as unhandled. + host.call((api) => api.smooth(1)).catch(() => undefined); + host.call((api) => api.smooth(2)).catch(() => undefined); + + expect(workers).toHaveLength(1); + // Both calls reached the same endpoint rather than being dropped. + await Promise.resolve(); + expect(workers[0].posted.length).toBeGreaterThanOrEqual(2); + }); + + it('rejects the calls in flight when the worker errors', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const first = host.call((api) => api.smooth(1)); + const second = host.call((api) => api.smooth(2)); + workers[0].emit({ type: 'error', message: 'Failed to load worker chunk' }); + + await expect(first).rejects.toThrow('Failed to load worker chunk'); + await expect(second).rejects.toThrow('Failed to load worker chunk'); + }); + + it('names the event when the failure carries no message', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const call = host.call((api) => api.smooth(1)); + workers[0].emit({ type: 'messageerror' }); + + await expect(call).rejects.toThrow(/messageerror/); + }); + + it('starts a fresh worker for the call after a failure', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const call = host.call((api) => api.smooth(1)); + workers[0].emit({ type: 'error', message: 'worker gone' }); + await expect(call).rejects.toThrow('worker gone'); + + host.call((api) => api.smooth(2)).catch(() => undefined); + + // The dead instance is dropped, so the next run is not answered by it. + expect(workers).toHaveLength(2); + }); + + it('ends the calls in flight when the host is terminated', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const call = host.call((api) => api.smooth(1)); + host.terminate(); + + await expect(call).rejects.toThrow(/stopped/); + expect(workers[0].terminated).toBe(true); + }); + + it('starts a fresh worker for the run after a terminate', async () => { + const { host, workers } = hostOverSilentWorkers(); + + host.call((api) => api.smooth(1)).catch(() => undefined); + terminateProcessWorkers(); + host.call((api) => api.smooth(2)).catch(() => undefined); + + expect(workers[0].terminated).toBe(true); + expect(workers).toHaveLength(2); + expect(workers[1].terminated).toBe(false); + }); +}); diff --git a/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts b/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts new file mode 100644 index 000000000..910796c3f --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import type { Vector3 } from '@kitware/vtk.js/types'; + +import { rasterizePolygon } from '@/src/segmentation/editing/rasterizePolygon'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { listMasks } from '@/src/segmentation/model'; +import { + addMask, + extentOf, + labelValueOf, + maskValueAt, + markedVoxels, + seatImage, + seedVoxel, + store, + segmentOfMask, + type Index3, + lockSegment, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +// --------------------------------------------------------------------------- +// The mask grows to hold the polygon before `fillPoly` runs (a mask that does +// not reach the polygon silently swallows every pixel), and the filled voxels +// are cleared in the other UNLOCKED segments of the image. A locked one keeps +// its voxels, so the two segments overlap there. +// +// Unit spacing and a zero origin make world points index points, and an +// identity direction maps the Axial view axis to K. +// --------------------------------------------------------------------------- + +const DIMENSIONS: Index3 = [6, 6, 2]; + +// fillPoly fills i in 1..4 and j in 2..4 for this square. +const SQUARE: Vector3[] = [ + [1, 1, 0], + [4, 1, 0], + [4, 4, 0], + [1, 4, 0], +]; + +const rasterize = (segmentId: string | undefined, points = SQUARE, slice = 0) => + rasterizePolygon({ + imageId: 'img-1', + segmentId, + points, + slice, + viewAxis: 'Axial', + }); + +/** What a polygon carries: the type, not the record it lands in. */ +const rasterizeInto = ( + maskId: string | undefined, + points = SQUARE, + slice = 0 +) => rasterize(maskId && segmentOfMask(maskId), points, slice); + +const segments = () => useSegmentStore().segments; + +const segmentNamesOf = (imageId: string) => + listMasks(store().getSegmentationForImage(imageId)!).map( + (segment) => segments().appearanceOf(segment.segmentId).name + ); + +describe('rasterizing a polygon into a bounded mask', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1', { dimensions: DIMENSIONS }); + }); + + it('grows the mask to hold the polygon and fills it', () => { + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + const labelValue = labelValueOf(maskId); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValue); + expect(maskValueAt(maskId, [3, 3, 0])).toBe(labelValue); + const extent = extentOf(maskId)!; + expect(extent[0]).toBeLessThanOrEqual(1); + expect(extent[1]).toBeGreaterThanOrEqual(4); + expect(extent[3]).toBeGreaterThanOrEqual(4); + expect([extent[4], extent[5]]).toEqual([0, 0]); + }); + + it('leaves everything outside the polygon alone', () => { + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + expect(maskValueAt(maskId, [0, 0, 0])).toBeFalsy(); + expect(maskValueAt(maskId, [5, 5, 0])).toBeFalsy(); + expect(maskValueAt(maskId, [2, 3, 1])).toBeFalsy(); + }); + + it('clears the filled voxels in another segment’s mask', () => { + const neighbor = addMask('img-1', 'Neighbour'); + seedVoxel(neighbor, [2, 3, 0]); + seedVoxel(neighbor, [0, 0, 0]); + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + expect(maskValueAt(neighbor, [2, 3, 0])).toBe(0); + expect(maskValueAt(neighbor, [0, 0, 0])).toBe(labelValueOf(neighbor)); + }); + + it('shares the filled voxels with a locked neighbour', () => { + const locked = addMask('img-1', 'Locked'); + const unlocked = addMask('img-1', 'Unlocked'); + seedVoxel(locked, [2, 3, 0]); + seedVoxel(unlocked, [2, 3, 0]); + lockSegment(locked, true); + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + expect(maskValueAt(locked, [2, 3, 0])).toBe(labelValueOf(locked)); + expect(maskValueAt(unlocked, [2, 3, 0])).toBe(0); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + }); + + it('publishes each changed neighbor once before returning from a fill', () => { + const neighbors = ['First', 'Second', 'Locked', 'Background'].map( + (name) => { + const id = addMask('img-1', name); + const voxels = store().maskVoxels(id); + voxels.materialize(); + voxels.ensureContains([0, 5, 0, 5, 0, 1]); + if (name !== 'Background') voxels.scalars().fill(1); + if (name === 'Locked') lockSegment(id, true); + const modified = vi.fn(); + voxels.image().onModified(modified); + return { id, modified }; + } + ); + const target = addMask('img-1', 'Target'); + + rasterizeInto(target); + + expect(neighbors.map(({ modified }) => modified.mock.calls.length)).toEqual( + [1, 1, 0, 0] + ); + expect(neighbors.map(({ id }) => maskValueAt(id, [2, 3, 0]))).toEqual([ + 0, 0, 1, 0, + ]); + // Repeating unchanged writes must not publish another sibling event. + rasterizeInto(target); + expect(neighbors[0].modified).toHaveBeenCalledTimes(1); + + rasterizeInto(target, SQUARE, 1); + expect(neighbors.map(({ modified }) => modified.mock.calls.length)).toEqual( + [2, 2, 0, 0] + ); + expect(maskValueAt(target, [2, 3, 1])).toBe(1); + expect(maskValueAt(neighbors[0].id, [0, 0, 0])).toBe(1); + }); + + it('creates nothing for a polygon that covers no voxel', () => { + // Resolving the target mints the record, its segmentation and its + // storage, so a polygon with nothing to fill is answered before that. + const empty = rasterize(undefined, []); + const outside = rasterize(undefined, [ + [-4, -4, 0], + [-2, -4, 0], + [-2, -2, 0], + [-4, -2, 0], + ]); + + expect(empty).toEqual({ segmentId: undefined, maskId: undefined }); + expect(outside).toEqual({ segmentId: undefined, maskId: undefined }); + expect(store().getSegmentationForImage('img-1')).toBeUndefined(); + }); + + it('consults the neighbours over the polygon, not the whole mask', () => { + const maskId = addMask('img-1', 'Tumor'); + // A mask over the whole image: its own box says nothing about where this + // polygon lands, and every neighbour touching it would be walked per + // filled pixel. + const voxels = store().maskVoxels(maskId); + voxels.materialize(); + voxels.ensureContains([0, 5, 0, 5, 0, 1]); + const voxelClaim = vi.spyOn(store(), 'voxelClaim'); + + rasterizeInto(maskId); + + expect(voxelClaim).toHaveBeenCalledTimes(1); + expect(voxelClaim.mock.calls[0][2]).toEqual([1, 4, 1, 4, 0, 0]); + }); + + it('refuses a locked segment and leaves every mask as it was', () => { + const neighbour = addMask('img-1', 'Neighbour'); + seedVoxel(neighbour, [2, 3, 0]); + const maskId = addMask('img-1', 'Tumor'); + lockSegment(maskId, true); + + const result = rasterizeInto(maskId); + + // Refused: the type is named back, but nothing was written into a record. + expect(result.maskId).toBeUndefined(); + expect(result.segmentId).toBe(segmentOfMask(maskId)); + expect(maskValueAt(maskId, [2, 3, 0])).toBeFalsy(); + // The clearer never ran, so the neighbour keeps what a fill would take. + expect(maskValueAt(neighbour, [2, 3, 0])).toBe(labelValueOf(neighbour)); + }); + + it('keeps an earlier polygon when a later one grows the mask', () => { + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + rasterizeInto( + maskId, + [ + [1, 1, 1], + [4, 1, 1], + [4, 4, 1], + [1, 4, 1], + ], + 1 + ); + + const labelValue = labelValueOf(maskId); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValue); + expect(maskValueAt(maskId, [2, 3, 1])).toBe(labelValue); + }); + + it('stays inside the parent image for a polygon that overhangs it', () => { + const maskId = addMask('img-1', 'Tumor'); + + expect(() => + rasterizeInto(maskId, [ + [-2, -2, 0], + [2, -2, 0], + [2, 2, 0], + [-2, 2, 0], + ]) + ).not.toThrow(); + + const extent = extentOf(maskId)!; + expect(extent[0]).toBe(0); + expect(extent[2]).toBe(0); + expect(maskValueAt(maskId, [1, 1, 0])).toBe(labelValueOf(maskId)); + }); + + it('rasterizes into a segment it resolves when the polygon carries none', () => { + const maskId = rasterize(undefined).maskId!; + + const segmentation = store().getSegmentationForImage('img-1')!; + expect(segmentation.order).toEqual([maskId]); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + }); + + it('rasterizes into the type the polygon names, not the selected one', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + const node = segments().addSegment({ name: 'Node' }); + // The user picks another type between placing the polygon and rasterizing + // it; the polygon still carries the type it was drawn with. + segments().selectSegment(node); + + const maskId = rasterize(tumor).maskId!; + + expect(segmentOfMask(maskId)).toBe(tumor); + expect(segmentNamesOf('img-1')).toEqual(['Tumor']); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + + const nextEdit = store().resolveEditTarget('img-1'); + expect(segmentOfMask(nextEdit)).toBe(node); + expect(segmentNamesOf('img-1')).toEqual(['Tumor', 'Node']); + }); + + it('rasterizes into the record its type already has here', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + + const first = rasterize(tumor); + const second = rasterize(tumor, SQUARE, 1); + + expect(second.maskId).toBe(first.maskId); + expect(segmentNamesOf('img-1')).toEqual(['Tumor']); + }); + + it('leaves the rasterized record for the next paint edit', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + + const rasterized = rasterize(tumor).maskId!; + const painted = store().resolveEditTarget('img-1'); + + expect(painted).toBe(rasterized); + expect(segments().selectedSegmentId.value).toBe(tumor); + expect(segmentNamesOf('img-1')).toEqual(['Tumor']); + }); + + it('rasterizes into the type it was given, not the selected one', () => { + const active = addMask('img-1', 'Active'); + segments().selectSegment(segmentOfMask(active)); + const named = addMask('img-1', 'Named'); + + const result = rasterizeInto(named); + + expect(result.maskId).toBe(named); + expect(maskValueAt(named, [2, 3, 0])).toBe(labelValueOf(named)); + expect(maskValueAt(active, [2, 3, 0])).toBeFalsy(); + }); +}); + +// --------------------------------------------------------------------------- +// The voxels a polygon fills are a property of the polygon and the parent +// image, not of how much of the image its mask currently holds. Triangles with +// integer vertices are where an edge can cross a scanline exactly on a pixel +// centre, which is the tie an allocation-dependent fill resolves differently. +// --------------------------------------------------------------------------- + +const GRID: Index3 = [40, 40, 1]; +const GRID_EXTENT = [0, 39, 0, 39, 0, 0] as const; + +const TRIANGLES: Vector3[][] = [ + [ + [18, 23, 0], + [27, 6, 0], + [5, 28, 0], + ], + [ + [2, 2, 0], + [30, 2, 0], + [2, 30, 0], + ], + [ + [10, 5, 0], + [35, 20, 0], + [6, 33, 0], + ], + [ + [7, 31, 0], + [33, 9, 0], + [20, 36, 0], + ], + [ + [1, 17, 0], + [38, 4, 0], + [22, 29, 0], + ], + [ + [12, 1, 0], + [29, 25, 0], + [3, 38, 0], + ], +]; + +describe('rasterizing a polygon whatever the mask already holds', () => { + const fillOn = (imageId: string, points: Vector3[], grown: boolean) => { + const maskId = addMask(imageId, 'Tumor'); + const voxels = store().maskVoxels(maskId); + voxels.materialize(); + if (grown) voxels.ensureContains([...GRID_EXTENT]); + rasterizePolygon({ + imageId, + segmentId: segmentOfMask(maskId), + points, + slice: 0, + viewAxis: 'Axial', + }); + return markedVoxels(maskId); + }; + + it('fills the same parent voxels into a fresh and an image-sized mask', async () => { + const fills = []; + for (const points of TRIANGLES) { + setActivePinia(createPinia()); + // Separate images so neither fill can claim the other's voxels. + await seatImage('fresh', { dimensions: GRID }); + await seatImage('grown', { dimensions: GRID }); + fills.push({ + fresh: fillOn('fresh', points, false), + grown: fillOn('grown', points, true), + }); + } + + expect(fills.map(({ fresh }) => fresh)).toEqual( + fills.map(({ grown }) => grown) + ); + expect(fills.every(({ fresh }) => fresh!.length > 0)).toBe(true); + }); +}); diff --git a/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts b/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts new file mode 100644 index 000000000..435bae0d1 --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + seatSpecImage as seatImage, + maskOn, + lockSegment, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +import { + rasterizeTargetDisabledReason, + resolveRasterizeTarget, +} from '@/src/segmentation/editing/rasterizePolygon'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +const store = () => useSegmentationStore(); +const segments = () => useSegmentStore().segments; + +const makeMask = (imageId: string, name: string) => { + const segmentId = segments().mintSegment({ name }); + return { segmentId, record: maskOn(imageId, segmentId) }; +}; + +const targetOf = (imageId: string, segmentId: string | undefined) => + resolveRasterizeTarget(imageId, segmentId)!; + +describe('polygon rasterize target', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('allocates storage for a record that has none', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + + const target = targetOf('img-1', segment.segmentId); + + expect(target.labelValue).toBe(SEGMENT_VALUE); + expect( + store() + .maskLayersForImage('img-1') + .map((layer) => layer.maskId) + ).toEqual([target.maskId]); + expect(target.voxels.image()).toBe( + store().findMaskBinding(target.maskId)!.image + ); + expect( + store().getMask(segment.record.id).representations.labelmap!.image + ).toBe(target.voxels.image()); + }); + + it('resolves the given type rather than the first one', async () => { + await seatImage('img-1'); + const first = makeMask('img-1', 'Other'); + store().maskVoxels(first.record.id).materialize(); + const second = makeMask('img-1', 'Tumor'); + + const target = targetOf('img-1', second.segmentId); + + expect(target.voxels.image()).not.toBe( + store().findMaskBinding(first.record.id)!.image + ); + }); + + it('reuses the same binding on a second rasterize', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + + const first = targetOf('img-1', segment.segmentId); + const second = targetOf('img-1', segment.segmentId); + + expect(second.voxels.image()).toBe(first.voxels.image()); + expect(store().maskLayersForImage('img-1')).toHaveLength(1); + }); + + it('leaves the selected type alone', async () => { + await seatImage('img-1'); + const active = makeMask('img-1', 'Active'); + const other = makeMask('img-1', 'Other'); + segments().selectSegment(active.segmentId); + + targetOf('img-1', other.segmentId); + + expect(segments().selectedSegmentId.value).toBe(active.segmentId); + }); + + it('takes this image record for a type painted on another image', async () => { + await seatImage('img-1'); + await seatImage('img-2'); + const elsewhere = makeMask('img-2', 'Tumor'); + + const target = targetOf('img-1', elsewhere.segmentId); + + expect(target.maskId).not.toBe(elsewhere.record.id); + expect(store().getMask(target.maskId).segmentId).toBe(elsewhere.segmentId); + }); + + it('refuses a locked record before allocating storage for it', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + lockSegment(segment.record.id, true); + + expect(resolveRasterizeTarget('img-1', segment.segmentId)).toBeUndefined(); + + expect( + store().getMask(segment.record.id).representations.labelmap + ).toBeUndefined(); + expect(store().maskLayersForImage('img-1')).toEqual([]); + expect( + useMessageStore().messages.map((message) => message.title) + ).toContain('Cannot rasterize into a locked segment'); + expect(rasterizeTargetDisabledReason(segment.segmentId)).toBe( + 'Unlock this segment to rasterize into it' + ); + }); + + it('describes the same selected fallback execution will use', async () => { + await seatImage('img-1'); + const stale = makeMask('img-1', 'Deleted'); + const fallback = makeMask('img-1', 'Selected'); + segments().selectSegment(fallback.segmentId); + segments().deleteSegment(stale.segmentId); + lockSegment(fallback.record.id, true); + + expect(rasterizeTargetDisabledReason(stale.segmentId)).toBe( + 'Unlock this segment to rasterize into it' + ); + expect(rasterizeTargetDisabledReason('')).toBe( + 'Unlock this segment to rasterize into it' + ); + + lockSegment(fallback.record.id, false); + expect(rasterizeTargetDisabledReason(stale.segmentId)).toBe(''); + expect(targetOf('img-1', stale.segmentId).segmentId).toBe( + fallback.segmentId + ); + }); + + it('describes the first-segment fallback without selecting or allocating it', async () => { + await seatImage('img-1'); + const first = makeMask('img-1', 'First'); + lockSegment(first.record.id, true); + + expect(segments().selectedSegmentId.value).toBeUndefined(); + expect(rasterizeTargetDisabledReason('')).toBe( + 'Unlock this segment to rasterize into it' + ); + expect(segments().selectedSegmentId.value).toBeUndefined(); + expect( + store().getMask(first.record.id).representations.labelmap + ).toBeUndefined(); + }); + + it('rasterizes into a minted type when nothing is selected', async () => { + await seatImage('img-1'); + + const target = targetOf('img-1', undefined); + + const segmentation = store().getSegmentationForImage('img-1'); + expect(Object.keys(segmentation!.masks)).toHaveLength(1); + expect(segments().selectedSegmentId.value).toBe(target.segmentId); + expect(target.voxels.image()).toBe( + store().findMaskBinding(target.maskId)!.image + ); + expect(target.maskId).toBe(Object.keys(segmentation!.masks)[0]); + }); + + it('reuses the default segment on a second rasterize', async () => { + await seatImage('img-1'); + + const first = targetOf('img-1', undefined); + const second = targetOf('img-1', undefined); + + expect(second.voxels.image()).toBe(first.voxels.image()); + expect(second.labelValue).toBe(first.labelValue); + expect( + Object.keys(store().getSegmentationForImage('img-1')!.masks) + ).toHaveLength(1); + }); + + it('hands back the accessor the polygon writes through', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + + const target = targetOf('img-1', segment.segmentId); + target.voxels.ensureContains([0, 3, 0, 0, 0, 0]); + // fillPoly writes voxel offsets into the live buffer, so a copy would be + // rasterized and thrown away. + target.voxels.scalars()[3] = target.labelValue; + + expect( + store() + .findMaskBinding(target.maskId)! + .image.getPointData() + .getScalars() + .getData()[3] + ).toBe(target.labelValue); + }); + + it('rasterizes into a minted type when the tool names a deleted one', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + segments().deleteSegment(segment.segmentId); + + // The tool keeps the deleted type's id; that must not block rasterizing. + const target = targetOf('img-1', segment.segmentId); + + expect(target.segmentId).not.toBe(segment.segmentId); + expect(store().getSegmentationForImage('img-1')!.masks).toHaveProperty( + target.maskId + ); + }); +}); diff --git a/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts b/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts new file mode 100644 index 000000000..7591ea1a8 --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createApp, nextTick } from 'vue'; +import type { Vector3 } from '@kitware/vtk.js/types'; + +import { CorePiniaProviderPlugin } from '@/src/core/provider'; +import { rasterizePolygon } from '@/src/segmentation/editing/rasterizePolygon'; +import { + addMask, + extentOf, + labelValueOf, + maskValueAt, + seatImage, + seedVoxel, + store, + type Index3, + selectSegment, + segmentOfMask, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { usePaintProcessStore } from '@/src/segmentation/editing/paintProcess'; +import { useViewStore } from '@/src/store/views'; +import type { Extent3D } from '@/src/segmentation/geometry'; + +const DIMENSIONS: Index3 = [6, 6, 1]; +const SQUARE: Vector3[] = [ + [1, 1, 0], + [4, 1, 0], + [4, 4, 0], + [1, 4, 0], +]; + +function growMask(maskId: string, extent: Extent3D) { + const voxels = store().maskVoxels(maskId); + voxels.materialize(); + voxels.ensureContains(extent); + selectSegment(maskId); +} + +function rasterize(maskId: string) { + return rasterizePolygon({ + imageId: 'img-1', + segmentId: segmentOfMask(maskId), + points: SQUARE, + slice: 0, + viewAxis: 'Axial', + }); +} + +async function setUpRasterizeView() { + const pinia = createPinia().use(CorePiniaProviderPlugin()); + createApp({}).use(pinia); + setActivePinia(pinia); + await seatImage('img-1', { dimensions: DIMENSIONS }); + useViewStore().setDataForAllViews('img-1'); + await nextTick(); +} + +function setUpOverlappingSegments(extent: Extent3D) { + const target = addMask('img-1', 'Target'); + growMask(target, extent); + const neighbor = addMask('img-1', 'Neighbor'); + seedVoxel(neighbor, [2, 3, 0]); + return { target, neighbor, labelValue: labelValueOf(target)! }; +} + +describe('polygon rasterize action', () => { + beforeEach(setUpRasterizeView); + + it('restores the original before rasterization grows the mask', async () => { + const { target, neighbor, labelValue } = setUpOverlappingSegments([ + 0, 1, 0, 1, 0, 0, + ]); + const processStore = usePaintProcessStore(); + + await processStore.startProcess(async ({ scalars, maskExtent }) => ({ + scalars: new Uint8Array(scalars.length).fill(labelValue), + extent: maskExtent, + })); + expect(maskValueAt(target, [0, 0, 0])).toBe(labelValue); + + rasterize(target); + + expect(processStore.processState.step).toBe('start'); + expect(extentOf(target)).toEqual([0, 4, 0, 4, 0, 0]); + expect(maskValueAt(target, [0, 0, 0])).toBe(0); + expect(maskValueAt(target, [2, 3, 0])).toBe(labelValue); + expect(maskValueAt(neighbor, [2, 3, 0])).toBe(0); + }); + + it('leaves same-sized rasterization intact after the preview is reset', async () => { + const { target, neighbor, labelValue } = setUpOverlappingSegments([ + 0, 5, 0, 5, 0, 0, + ]); + seedVoxel(target, [0, 0, 0]); + const processStore = usePaintProcessStore(); + + await processStore.startProcess(async ({ scalars, maskExtent }) => ({ + scalars: new Uint8Array(scalars.length), + extent: maskExtent, + })); + expect(maskValueAt(target, [0, 0, 0])).toBe(0); + + rasterize(target); + processStore.cancelProcess(); + processStore.setShowingOriginal(true); + + expect(processStore.processState.step).toBe('start'); + expect(extentOf(target)).toEqual([0, 5, 0, 5, 0, 0]); + expect(maskValueAt(target, [0, 0, 0])).toBe(labelValue); + expect(maskValueAt(target, [2, 3, 0])).toBe(labelValue); + expect(maskValueAt(neighbor, [2, 3, 0])).toBe(0); + }); +}); diff --git a/src/segmentation/editing/__tests__/silentWorker.ts b/src/segmentation/editing/__tests__/silentWorker.ts new file mode 100644 index 000000000..85a08b2e2 --- /dev/null +++ b/src/segmentation/editing/__tests__/silentWorker.ts @@ -0,0 +1,59 @@ +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; + +/** + * A Comlink endpoint that accepts messages and never answers one, which is + * what a worker that failed to load, or that is busy with a job nobody wants + * any more, looks like from the page. + */ +export class SilentWorker { + static created: SilentWorker[] = []; + + private listeners = new Map void>>(); + + posted: unknown[] = []; + + terminated = false; + + constructor() { + SilentWorker.created.push(this); + } + + addEventListener(type: string, listener: (event: unknown) => void) { + const forType = this.listeners.get(type) ?? []; + forType.push(listener); + this.listeners.set(type, forType); + } + + removeEventListener(type: string, listener: (event: unknown) => void) { + const forType = this.listeners.get(type) ?? []; + this.listeners.set( + type, + forType.filter((entry) => entry !== listener) + ); + } + + postMessage(message: unknown) { + this.posted.push(message); + } + + terminate() { + this.terminated = true; + } + + /** What the browser does to a worker that fails: an event, no reply. */ + emit(event: { type: string; message?: string }) { + [...(this.listeners.get(event.type) ?? [])].forEach((listener) => + listener(event) + ); + } +} + +export type SilentApi = { smooth: (value: number) => Promise }; + +export function hostOverSilentWorkers() { + SilentWorker.created = []; + const host = createProcessWorkerHost( + () => new SilentWorker() as unknown as Worker + ); + return { host, workers: SilentWorker.created }; +} diff --git a/src/core/tools/paint/__tests__/fillHoles.spec.ts b/src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts similarity index 50% rename from src/core/tools/paint/__tests__/fillHoles.spec.ts rename to src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts index 1f77280bd..255854b9b 100644 --- a/src/core/tools/paint/__tests__/fillHoles.spec.ts +++ b/src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { fillHoles } from '../fillHoles'; +import { fillHoles } from '@/src/segmentation/editing/algorithms/fillHoles'; // Build a flat single-slice label map (axis 2, k=0) from a 2D grid. // grid[j][i] maps to flat index i + j*dimI. @@ -10,6 +10,13 @@ function flatFromGrid(grid: number[][]) { return { data, dimensions: [dimI, dimJ, 1] as [number, number, number] }; } +const fillAxialSlice = ( + data: Uint8Array, + dimensions: [number, number, number] +) => fillHoles({ data, dimensions, axis: 2, sliceIndex: 0, label: 1 }); + +const flatOf = (grid: number[][]) => Array.from(flatFromGrid(grid).data); + describe('fillHoles', () => { it('fills a background hole enclosed by a single segment', () => { const { data, dimensions } = flatFromGrid([ @@ -17,15 +24,13 @@ describe('fillHoles', () => { [1, 0, 1], [1, 1, 1], ]); - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); + const out = fillAxialSlice(data, dimensions); expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [1, 1, 1], - [1, 1, 1], - [1, 1, 1], - ]).data - ) + flatOf([ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]) ); }); @@ -35,16 +40,14 @@ describe('fillHoles', () => { [1, 0, 1], [1, 1, 1], ]); - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); + const out = fillAxialSlice(data, dimensions); // The top-left 0 reaches the border, so it stays 0; the center is enclosed. expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [0, 1, 1], - [1, 1, 1], - [1, 1, 1], - ]).data - ) + flatOf([ + [0, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]) ); }); @@ -55,22 +58,11 @@ describe('fillHoles', () => { [1, 1, 1], ]); const before = Array.from(data); - fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); + fillAxialSlice(data, dimensions); expect(Array.from(data)).toEqual(before); }); - it('all-segments: fills a hole with the majority bordering label', () => { - const { data, dimensions } = flatFromGrid([ - [5, 5, 5], - [7, 0, 5], - [5, 5, 5], - ]); - // The center 0 borders three 5s and one 7, so the majority is 5. - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - expect(out[1 + 1 * 3]).toBe(5); - }); - - it('selected-segment: fills enclosed background but preserves encircled segments', () => { + it('fills enclosed background but preserves encircled segments', () => { // 7 wide x 5 tall. Left block is a ring of 1 enclosing 0s and 2s; a stray // 2 sits outside the ring on the right border. const { data, dimensions } = flatFromGrid([ @@ -80,28 +72,20 @@ describe('fillHoles', () => { [1, 0, 2, 0, 1, 0, 0], [1, 1, 1, 1, 1, 0, 0], ]); - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - label: 1, - }); + const out = fillAxialSlice(data, dimensions); // Enclosed background (0) becomes 1; the enclosed 2s stay 2. expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [1, 1, 1, 1, 1, 0, 2], - [1, 1, 2, 1, 1, 0, 0], - [1, 2, 2, 2, 1, 0, 0], - [1, 1, 2, 1, 1, 0, 0], - [1, 1, 1, 1, 1, 0, 0], - ]).data - ) + flatOf([ + [1, 1, 1, 1, 1, 0, 2], + [1, 1, 2, 1, 1, 0, 0], + [1, 2, 2, 2, 1, 0, 0], + [1, 1, 2, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + ]) ); }); - it('selected-segment: does not override a segment it fully encircles', () => { + it('does not override a segment it fully encircles', () => { // Segment 1 forms a ring around segment 2 with a background gap between. const { data, dimensions } = flatFromGrid([ [1, 1, 1, 1, 1], @@ -110,24 +94,16 @@ describe('fillHoles', () => { [1, 0, 0, 0, 1], [1, 1, 1, 1, 1], ]); - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - label: 1, - }); + const out = fillAxialSlice(data, dimensions); // The background gap fills with 1; the encircled 2 is untouched. expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 2, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - ]).data - ) + flatOf([ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 2, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + ]) ); }); @@ -141,7 +117,7 @@ describe('fillHoles', () => { // A border voxel that must stay 0 (corner of the i=0 plane). data[0] = 0; - const out = fillHoles({ data, dimensions, axis: 0 }); + const out = fillHoles({ data, dimensions, axis: 0, label: 1 }); for (let i = 0; i < 3; i += 1) { expect(out[holeOffset(i)]).toBe(1); } @@ -154,56 +130,15 @@ describe('fillHoles', () => { const holeOffset = (i: number) => i + 1 * 3 + 1 * 9; for (let i = 0; i < 3; i += 1) data[holeOffset(i)] = 0; - const out = fillHoles({ data, dimensions, axis: 0, sliceIndex: 1 }); - expect(out[holeOffset(0)]).toBe(0); - expect(out[holeOffset(1)]).toBe(1); - expect(out[holeOffset(2)]).toBe(0); - }); - - it('all-segments: breaks majority ties by the lowest label', () => { - // The center borders two 8s (reached first by the flood) and two 3s. The - // lower label must win regardless of traversal order, so this fails if ties - // fall back to insertion order. - const { data, dimensions } = flatFromGrid([ - [8, 8, 3], - [8, 0, 3], - [8, 3, 3], - ]); - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - expect(out[1 + 1 * 3]).toBe(3); - }); - - it('all-segments: does not grow a locked segment into a hole', () => { - const { data, dimensions } = flatFromGrid([ - [5, 5, 5], - [5, 0, 5], - [5, 5, 5], - ]); - // 5 is locked, so its enclosed hole is left as background. const out = fillHoles({ data, dimensions, - axis: 2, - sliceIndex: 0, - lockedLabels: [5], - }); - expect(out[1 + 1 * 3]).toBe(0); - }); - - it('all-segments: fills with the unlocked majority even when a locked label borders', () => { - const { data, dimensions } = flatFromGrid([ - [5, 5, 5], - [7, 0, 5], - [5, 5, 5], - ]); - // Majority 5 (unlocked) wins over the single locked 7 neighbor. - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - lockedLabels: [7], + axis: 0, + sliceIndex: 1, + label: 1, }); - expect(out[1 + 1 * 3]).toBe(5); + expect(out[holeOffset(0)]).toBe(0); + expect(out[holeOffset(1)]).toBe(1); + expect(out[holeOffset(2)]).toBe(0); }); }); diff --git a/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts b/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts index 4a809e805..6332076d6 100644 --- a/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts +++ b/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts @@ -19,7 +19,7 @@ function smooth( maskExtent, parentDimensions, params: { sigma: 1, label: LABEL }, - }); + })!; const [pi, pj, pk] = parentDimensions; const parent = new Uint8Array(pi * pj * pk); let offset = 0; @@ -114,9 +114,19 @@ describe('gaussianSmoothLabelMapWorker', () => { expect(awayFromFace[0 + 3 * 9 + 3 * 81]).toBe(0); }); - it('leaves a buffer with none of the label alone', () => { - expect(Array.from(smooth(new Uint8Array([0, 1, 0, 1]), [4, 1, 1]))).toEqual( - [0, 1, 0, 1] - ); + it('says a buffer with none of the label has nothing to do', () => { + // Not a copy of the input: an identical result would put the user in a + // preview whose two states are the same. + const dimensions: Dims = [4, 1, 1]; + expect( + gaussianSmoothLabelMapWorker({ + data: new Uint8Array([0, 1, 0, 1]), + dimensions, + spacing: [1, 1, 1], + maskExtent: fullExtent(dimensions), + parentDimensions: dimensions, + params: { sigma: 1, label: LABEL }, + }) + ).toBeUndefined(); }); }); diff --git a/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts b/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts index be65c6a3d..81f39147d 100644 --- a/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts +++ b/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts @@ -215,7 +215,7 @@ const smooth = (sigma: number, spacing: [number, number, number]) => maskExtent: [0, 8, 0, 7, 0, 6], parentDimensions: DIMENSIONS, params: { sigma, label: LABEL }, - }).scalars; + })!.scalars; const asRows = (output: ArrayLike) => { const bits = Array.from(output, (value) => (value ? '1' : '0')).join(''); diff --git a/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts b/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts index 54a130925..6f06393ec 100644 --- a/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts +++ b/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts @@ -318,16 +318,11 @@ export function gaussianSmoothLabelMapWorker(input: GaussianSmoothInput) { sigma / spacing[2], ]; - // Absent when the label is nowhere in the mask, which is also the whole - // answer for a mask with nothing to smooth: it comes back as it went in. + // Absent when the label is nowhere in the mask. There is then nothing to + // smooth, which is the caller's "nothing to do": handing back a copy of the + // input instead would open a preview between two identical states. const bounds = calculateBoundingBox(originalData, dimensions, label); - if (!bounds) { - const outputData = createTypedArrayLike(originalData, originalData.length); - for (let i = 0; i < originalData.length; i++) { - outputData[i] = originalData[i]; - } - return { scalars: outputData, extent: maskExtent }; - } + if (!bounds) return undefined; const expandedBounds = expandBoundingBox({ bounds, diff --git a/src/segmentation/editing/fillBetween.ts b/src/segmentation/editing/fillBetween.ts new file mode 100644 index 000000000..7345d8a78 --- /dev/null +++ b/src/segmentation/editing/fillBetween.ts @@ -0,0 +1,51 @@ +import { defineStore } from 'pinia'; +import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; +import { TypedArray } from '@kitware/vtk.js/types'; +import { morphologicalContourInterpolation } from '@itk-wasm/morphological-contour-interpolation'; +import type { Image } from 'itk-wasm'; +import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import { reframeMaskScalars } from '@/src/segmentation/masks/storage'; +import { fullExtent } from '@/src/segmentation/geometry'; + +type Interpolate = ( + image: Image, + options: { label: number } +) => Promise<{ outputImage: Image }>; + +export const useFillBetweenStore = defineStore('fillBetween', () => { + async function computeAlgorithm( + target: ProcessTarget, + interpolate: Interpolate = morphologicalContourInterpolation + ) { + const image = vtkImageData.newInstance({ + origin: target.parentOrigin, + spacing: target.spacing, + direction: target.direction, + }); + image.setDimensions(target.parentDimensions); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: reframeMaskScalars( + target.scalars, + target.maskExtent, + fullExtent(target.parentDimensions) + ), + }) + ); + const extent = fullExtent(target.parentDimensions); + // Interpolation's alignment and dilation depend on image boundaries, and + // can leave the input contours' box. Only this transient input spans the + // parent; the process commits the occupied result to bounded storage. + const input = vtkITKHelper.convertVtkToItkImage(image); + image.delete(); + const out = await interpolate(input, { label: target.labelValue }); + return { scalars: out.outputImage.data as TypedArray, extent }; + } + + return { + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/fillHoles.ts b/src/segmentation/editing/fillHoles.ts new file mode 100644 index 000000000..a56c51c07 --- /dev/null +++ b/src/segmentation/editing/fillHoles.ts @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import * as Comlink from 'comlink'; +import { useViewStore } from '@/src/store/views'; +import { useViewSliceStore } from '@/src/store/view-configs/slicing'; +import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import { getEffectiveView } from '@/src/core/views/effectiveView'; +import { fillHolesWorker } from '@/src/segmentation/editing/algorithms/fillHoles.worker'; +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; +import { getLPSDirections } from '@/src/utils/lps'; +import type { LPSAxis } from '@/src/types/lps'; + +export enum FillHolesSliceScope { + CurrentSlice = 'currentSlice', + WholeVolume = 'wholeVolume', +} + +export enum FillHolesSegmentScope { + AllSegments = 'allSegments', + SelectedSegment = 'selectedSegmentOn', +} + +type WorkerApi = { + fillHolesWorker: typeof fillHolesWorker; +}; + +const workerHost = createProcessWorkerHost( + () => + new Worker( + new URL( + '@/src/segmentation/editing/algorithms/fillHoles.worker.ts', + import.meta.url + ), + { type: 'module' } + ) +); + +/** + * The current parent slice in the mask's own index space, or undefined when the + * mask does not reach it. A segment's mask is cropped to its own extent, so a + * slice outside it converts to an index the worker would fold back onto a real + * slice of the mask and fill the wrong one. + */ +function maskSliceIndex( + view: { viewInfo: { id: string }; axis: LPSAxis }, + target: ProcessTarget, + axis: number +) { + const sliceConfig = useViewSliceStore().getConfig( + view.viewInfo.id, + target.parentImageId + ); + const sliceIndex = sliceConfig.slice - target.maskExtent[axis * 2]; + const sliceCount = target.dimensions[axis]; + return sliceIndex < 0 || sliceIndex >= sliceCount ? undefined : sliceIndex; +} + +export const useFillHolesStore = defineStore('fillHoles', () => { + const sliceScope = ref(FillHolesSliceScope.CurrentSlice); + const segmentScope = ref(FillHolesSegmentScope.AllSegments); + + function setSliceScope(value: FillHolesSliceScope) { + sliceScope.value = value; + } + + function setSegmentScope(value: FillHolesSegmentScope) { + segmentScope.value = value; + } + + async function computeAlgorithm(target: ProcessTarget) { + const viewStore = useViewStore(); + + // Fill Holes works on the slice plane of the 2D view the user is on, so a + // 2D view must be active to know which axis (and slice) to operate on. + const effectiveView = getEffectiveView(viewStore.activeView); + if (effectiveView?.kind !== 'volume2D') { + throw new Error( + 'Fill Holes needs an active 2D slice view. Click a 2D view, then try again.' + ); + } + + const labelMapLpsOrientation = getLPSDirections( + Float32Array.from(target.direction) + ); + const axis = labelMapLpsOrientation[effectiveView.axis]; + const { dimensions, scalars: data } = target; + + const currentSlice = sliceScope.value === FillHolesSliceScope.CurrentSlice; + const sliceIndex = currentSlice + ? maskSliceIndex(effectiveView, target, axis) + : undefined; + if (currentSlice && sliceIndex === undefined) { + // The user named one segment, so say the slice misses it. An + // all-segments pass simply has nothing to do in this one. + if (segmentScope.value === FillHolesSegmentScope.SelectedSegment) { + throw new Error( + 'the selected segment has nothing on this slice. Scroll to a slice it covers, then try again.' + ); + } + return undefined; + } + + // The input is the process manager's own detached copy, and nothing reads + // it once the worker has it, so the buffer moves to the worker rather than + // being cloned into it: one mask's worth of bytes less per run. + const scalars = await workerHost.call((worker) => + worker.fillHolesWorker( + Comlink.transfer( + { + data, + dimensions, + axis, + sliceIndex, + label: target.labelValue, + }, + [data.buffer as ArrayBuffer] + ) + ) + ); + return { scalars, extent: target.maskExtent }; + } + + return { + sliceScope, + segmentScope, + setSliceScope, + setSegmentScope, + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/gaussianSmooth.ts b/src/segmentation/editing/gaussianSmooth.ts new file mode 100644 index 000000000..0008d977f --- /dev/null +++ b/src/segmentation/editing/gaussianSmooth.ts @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import * as Comlink from 'comlink'; +import { gaussianSmoothLabelMapWorker } from '@/src/segmentation/editing/algorithms/gaussianSmooth.worker'; +import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; + +export const DEFAULT_SIGMA = 1.0; +export const MIN_SIGMA = 0.1; +export const MAX_SIGMA = 5.0; + +// Worker management +type WorkerApi = { + gaussianSmoothLabelMapWorker: typeof gaussianSmoothLabelMapWorker; +}; + +const workerHost = createProcessWorkerHost( + () => + new Worker( + new URL( + '@/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts', + import.meta.url + ), + { type: 'module' } + ) +); + +async function gaussianSmoothLabelMap( + target: ProcessTarget, + params: { sigma: number; label: number } +) { + const workerInput = { + data: target.scalars, + dimensions: target.dimensions, + spacing: target.spacing, + maskExtent: target.maskExtent, + parentDimensions: target.parentDimensions, + params, + }; + + // The input is the process manager's own detached copy, and nothing reads it + // once the worker has it, so the buffer moves to the worker rather than being + // cloned into it: one mask's worth of bytes less per run. + return workerHost.call((worker) => + worker.gaussianSmoothLabelMapWorker( + Comlink.transfer(workerInput, [target.scalars.buffer as ArrayBuffer]) + ) + ); +} + +export const useGaussianSmoothStore = defineStore('gaussianSmooth', () => { + const sigma = ref(DEFAULT_SIGMA); + + function setSigma(value: number) { + sigma.value = Math.max(MIN_SIGMA, Math.min(MAX_SIGMA, value)); + } + + async function computeAlgorithm(target: ProcessTarget) { + const params = { + sigma: sigma.value, + label: target.labelValue, + }; + + return gaussianSmoothLabelMap(target, params); + } + + return { + sigma, + setSigma, + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/paintProcess.ts b/src/segmentation/editing/paintProcess.ts new file mode 100644 index 000000000..6af09b4e8 --- /dev/null +++ b/src/segmentation/editing/paintProcess.ts @@ -0,0 +1,637 @@ +import { defineStore } from 'pinia'; +import { ref, computed, watch } from 'vue'; +import { TypedArray } from '@kitware/vtk.js/types'; +import { + LABELMAP_BACKGROUND_VALUE, + type VoxelStorage, +} from '@/src/segmentation/model'; +import { + extentContains, + extentSize, + extentUnion, + fullExtent, + isEmptyExtent, + markedExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { usePaintToolStore } from '@/src/store/tools/paint'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { PaintMode } from '@/src/core/tools/paint'; +import { useMessageStore } from '@/src/store/messages'; +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { reframeMaskScalars } from '@/src/segmentation/masks/storage'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import { terminateProcessWorkers } from '@/src/segmentation/editing/processWorker'; + +export enum ProcessType { + FillHoles = 'fillHoles', + FillBetween = 'fillBetween', + GaussianSmooth = 'gaussianSmooth', +} + +type StartState = { + step: 'start'; +}; + +type TargetedState = { + activeParentImageID: string; + targetMaskIds: string[]; + // The segment whose selection owns the run, absent for an all-segments run: + // that run belongs to no one segment, so no selection change is about it. + watchedMaskId?: string; +}; + +type ComputingState = TargetedState & { + step: 'computing'; +}; + +type PreviewRun = { + target: ResolvedTarget; + extent: Extent3D; + originalScalars: TypedArray; + processedScalars: TypedArray | number[]; +}; + +/** Algorithm output positioned in parent index space, including any growth. */ +export type ProcessResult = { + scalars: TypedArray | number[]; + extent: Extent3D; +}; + +type PreviewingState = TargetedState & { + step: 'previewing'; + runs: PreviewRun[]; + showingOriginal: boolean; +}; + +type ProcessState = StartState | ComputingState | PreviewingState; + +/** Detached algorithm input. Mutating it cannot change a stored mask. */ +export type ProcessTarget = { + parentImageId: string; + parentDimensions: [number, number, number]; + parentOrigin: number[]; + maskId: string; + scalars: TypedArray; + dimensions: [number, number, number]; + spacing: [number, number, number]; + direction: number[]; + maskExtent: Extent3D; + labelValue: number; +}; + +type ResolvedTarget = ProcessTarget & { voxels: VoxelStorage }; + +type ResolvedRun = { + targets: ResolvedTarget[]; + watchedMaskId?: string; +}; + +/** + * One segment's new mask contents, or `undefined` where the algorithm has + * nothing to do to that segment: a run with no result is dropped rather than + * written back, so an untouched mask keeps its buffer and the renderer is not + * invalidated for it. + */ +export type ProcessAlgorithm = ( + target: ProcessTarget +) => Promise; + +/** Validate placement and keep only the space the preview or original needs. */ +function previewExtent(target: ProcessTarget, result: ProcessResult) { + if ( + !result.extent.every(Number.isInteger) || + isEmptyExtent(result.extent) || + !extentContains(fullExtent(target.parentDimensions), result.extent) || + extentSize(result.extent).reduce((a, b) => a * b, 1) !== + result.scalars.length + ) { + throw new Error('Process result does not fit its parent-grid extent'); + } + if (extentContains(target.maskExtent, result.extent)) + return target.maskExtent; + const occupied = markedExtent( + result.scalars, + result.extent, + target.labelValue + ); + return isEmptyExtent(occupied) + ? target.maskExtent + : extentUnion(target.maskExtent, occupied); +} + +export const usePaintProcessStore = defineStore('paintProcess', () => { + const processState = ref({ step: 'start' }); + const activeProcessType = ref(ProcessType.FillHoles); + let activeProcessRunId = 0; + + const processStep = computed(() => processState.value.step); + + const showingOriginal = computed(() => { + const state = processState.value; + return state.step === 'previewing' ? state.showingOriginal : false; + }); + + const edits = useSegmentationEditsStore(); + + function resetState() { + edits.release(cancelProcess); + processState.value = { step: 'start' }; + } + + // Storage can be deleted while a preview is up, and the accessor re-resolves, + // so every preview write is conditional on the storage still being there. + // A mask another tool grew no longer has the shape the snapshot was taken + // at, and a snapshot of the old shape cannot be written back at all. + function writeIfPresent( + voxels: VoxelStorage, + scalars: TypedArray | number[] + ) { + if (!voxels.exists()) return; + if (voxels.scalars().length !== scalars.length) return; + voxels.apply(scalars); + } + + /** + * The extent and strides a run's mask offsets are taken against, absent when + * the mask has been reshaped since and they no longer address it. The binding + * is read here because `target.voxels` carries none and growth moves it. + */ + function runMaskBounds(run: PreviewRun) { + const binding = segmentationStore.findMaskBinding(run.target.maskId); + if (!binding || isEmptyExtent(binding.extent)) return undefined; + const extent = [...binding.extent] as Extent3D; + const [mi, mj] = extentSize(extent); + const addressable = extent.every( + (value, axis) => value === run.extent[axis] + ); + return addressable ? { extent, mi, mj } : undefined; + } + + /** + * Drops from the result every voxel the algorithm turned on that another + * segment already holds: a process is a `sweep`, so it takes nothing from a + * neighbour. Masking the result rather than the storage keeps the preview + * honest: what it shows is what confirm leaves behind. + */ + function maskVoxelsOtherSegmentsHold(run: PreviewRun) { + const bounds = runMaskBounds(run); + if (!bounds) return; + + // Absent when no other segment's box reaches this one, which is the common + // case: nothing can then be dropped, so the result is not walked at all. + const claimVoxel = segmentationStore.voxelClaim( + run.target.maskId, + 'sweep', + bounds.extent + ); + if (!claimVoxel) return; + + const { extent } = bounds; + const before = run.originalScalars; + const after = run.processedScalars; + const [ni, nj, nk] = extentSize(extent); + // Rows flat in one loop, as the mask sweeps are: a voxel's parent index is + // then a step along i from the row's own start, not a divide per voxel. + for (let row = 0; row < nj * nk; row += 1) { + const j = extent[2] + (row % nj); + const k = extent[4] + Math.floor(row / nj); + const from = row * ni; + for (let n = 0; n < ni; n += 1) { + const turnedOn = + after[from + n] !== LABELMAP_BACKGROUND_VALUE && + before[from + n] === LABELMAP_BACKGROUND_VALUE; + if (turnedOn && !claimVoxel.claim(extent[0] + n, j, k)) { + after[from + n] = LABELMAP_BACKGROUND_VALUE; + } + } + } + } + + /** + * One segment's preview slot, or nothing when the algorithm changed nothing + * or its storage went away while the algorithm ran. Both arrays are placed + * on the same extent before storage grows, so toggling or cancelling also + * restores voxels outside the input allocation. An algorithm must return a + * separate buffer: aliasing storage would erase its result on the first toggle. + */ + function buildRun( + target: ResolvedTarget, + originalScalars: TypedArray, + result: ProcessResult | undefined + ): PreviewRun[] { + if (result === undefined) return []; + if (!target.voxels.exists()) return []; + if (result.scalars === target.voxels.scalars()) { + throw new Error('Process returned the storage buffer it was given'); + } + const extent = previewExtent(target, result); + const binding = segmentationStore.findMaskBinding(target.maskId); + if ( + !binding?.extent.every((value, axis) => value === target.maskExtent[axis]) + ) + return []; + return [ + { + target, + extent, + originalScalars: extent.every( + (value, axis) => value === target.maskExtent[axis] + ) + ? originalScalars + : reframeMaskScalars(originalScalars, target.maskExtent, extent), + processedScalars: extent.every( + (value, axis) => value === result.extent[axis] + ) + ? result.scalars + : reframeMaskScalars(result.scalars, result.extent, extent), + }, + ]; + } + + function confirmProcess() { + if (cancelIfLocked()) return; + const state = processState.value; + // Apply commits the processed result. When the user is viewing the + // original, the masks currently hold originalScalars, so restore the + // processed scalars before finishing or the result is silently discarded. + if (state.step === 'previewing' && state.showingOriginal) { + state.runs.forEach((run) => + writeIfPresent(run.target.voxels, run.processedScalars) + ); + } + resetState(); + paintStore.restoreModeAfterProcess(); + } + + const segmentationStore = useSegmentationStore(); + const segmentRegistry = useSegmentStore().segments; + const imageCacheStore = useImageCacheStore(); + const paintStore = usePaintToolStore(); + const messageStore = useMessageStore(); + const { currentImageID } = useCurrentImage('global'); + + function cancelProcess() { + const state = processState.value; + + if (state.step === 'previewing') { + state.runs.forEach((run) => + writeIfPresent(run.target.voxels, run.originalScalars) + ); + } + // A run still computing has jobs sitting in the workers, one of which is + // running now and cannot be called back. Their results are already + // discarded, so the worker goes with them: the run that replaces this one + // starts on a fresh worker instead of waiting behind abandoned work. + if (state.step === 'computing') terminateProcessWorkers(); + resetState(); + paintStore.restoreModeAfterProcess(); + } + + // Locking any target cancels the whole uncommitted transaction. Rollback + // restores the original contents even though further edits are now locked. + const targetLocked = computed(() => { + const state = processState.value; + return ( + state.step !== 'start' && + state.targetMaskIds.some((maskId) => segmentationStore.isLocked(maskId)) + ); + }); + + function cancelIfLocked() { + if (!targetLocked.value) return false; + cancelProcess(); + return true; + } + + // Synchronous invalidation also honors a lock/unlock before Vue's next flush. + watch(targetLocked, cancelIfLocked, { flush: 'sync' }); + + function setActiveProcessType(processType: ProcessType) { + // Cancel any active process before switching + cancelProcess(); + activeProcessType.value = processType; + } + + // Distinguishes "every target's mask vanished mid-run" from "the algorithm + // looked and found nothing", which alone is worth telling the user about. + function warnIfNothingProcessed( + processType: ProcessType, + outputs: Awaited>[] + ) { + if (!outputs.every((output) => output === undefined)) return; + messageStore.addWarning( + `${processType} had nothing to do`, + 'No segment has anything on this slice. Scroll to a slice a segment covers, then try again.' + ); + } + + function targetFor(parentImageId: string, maskId: string) { + const parent = segmentationStore.getSegmentationForImage(parentImageId); + const voxels = segmentationStore.maskVoxels(maskId); + const binding = voxels.binding(); + const image = parent && imageCacheStore.getVtkImageData(parentImageId); + if ( + !binding || + isEmptyExtent(binding.extent) || + !voxels.exists() || + !image + ) { + return undefined; + } + return { + parentImageId, + parentDimensions: [...image.getDimensions()] as [number, number, number], + parentOrigin: Array.from(image.getOrigin()), + maskId, + voxels, + scalars: voxels.snapshot(), + dimensions: [...binding.image.getDimensions()] as [ + number, + number, + number, + ], + spacing: [...binding.image.getSpacing()] as [number, number, number], + direction: Array.from(binding.image.getDirection()), + maskExtent: [...binding.extent] as Extent3D, + labelValue: SEGMENT_VALUE, + } satisfies ResolvedTarget; + } + + function resolveSegmentScoped(imageId: string): ResolvedRun | undefined { + const maskId = segmentationStore.findEditTarget(imageId); + if (!maskId) { + messageStore.addError('No active segment selected'); + return undefined; + } + if (segmentationStore.isLocked(maskId)) { + messageStore.addError('Cannot process locked segment'); + return undefined; + } + const target = targetFor(imageId, maskId); + if (!target) { + messageStore.addError('No segment content to process'); + return undefined; + } + return { + targets: [target], + watchedMaskId: maskId, + }; + } + + // An image with segments on it and nothing editable is refusing for a reason + // the user can act on, so it does not get the empty image's message. + function nothingEditable(imageId: string) { + const masks = segmentationStore.imageMasks(imageId); + if (masks.length === 0) return 'No segmentation to process'; + return masks.every((mask) => segmentationStore.isLocked(mask.id)) + ? 'Every segment is locked' + : 'No unlocked segment has anything to process'; + } + + // All-segments: one run per editable segment, each on its own bounded mask. + // Nothing is created, and an image with no editable segment has nothing to + // process. + function resolveEverySegment(imageId: string): ResolvedRun | undefined { + const targets = segmentationStore + .editableMasks(imageId) + .flatMap(({ maskId }) => { + const target = targetFor(imageId, maskId); + return target ? [target] : []; + }); + if (targets.length === 0) { + messageStore.addError(nothingEditable(imageId)); + return undefined; + } + // No watched segment: the selection is not part of the target, so moving + // off it is not a reason to throw the run away. + return { targets }; + } + + // A process holding storage the user can still cancel out of. + const runInFlight = () => { + const state = processState.value; + return state.step === 'computing' || state.step === 'previewing' + ? state + : undefined; + }; + + // A run the user has already moved past: another process started, or the + // state machine left the step this one is finishing. + const runIsStale = (processRunId: number) => + processRunId !== activeProcessRunId || + processState.value.step !== 'computing'; + + async function startProcess( + algorithm: ProcessAlgorithm, + options?: { requiresActiveSegment?: boolean } + ) { + // Most processes operate on the active segment; all-segments processes opt + // out so they are not blocked by a locked active segment. + const imageId = currentImageID.value; + if (!imageId) { + messageStore.addError('No image to process'); + return; + } + + edits.beforeEdit(); + + const resolveRun = + options?.requiresActiveSegment === false + ? resolveEverySegment + : resolveSegmentScoped; + const resolved = resolveRun(imageId); + if (!resolved) return; + const { targets, watchedMaskId } = resolved; + + const processType = activeProcessType.value; + const processRunId = ++activeProcessRunId; + + const snapshots = targets.map((target) => target.scalars); + let runs: PreviewRun[] = []; + + const targetedState = { + activeParentImageID: imageId, + watchedMaskId, + targetMaskIds: targets.map((target) => target.maskId), + }; + edits.hold(cancelProcess); + paintStore.enterProcessMode(); + processState.value = { step: 'computing', ...targetedState }; + + try { + // Started together, so every algorithm reads its own mask before any + // result is written back: each run sees the state the user acted on. + const outputs = await Promise.all( + targets.map((input) => + algorithm({ + parentImageId: input.parentImageId, + maskId: input.maskId, + labelValue: input.labelValue, + scalars: input.scalars.slice(), + maskExtent: [...input.maskExtent], + dimensions: [...input.dimensions], + spacing: [...input.spacing], + direction: [...input.direction], + parentOrigin: [...input.parentOrigin], + parentDimensions: [...input.parentDimensions], + }) + ) + ); + + if (runIsStale(processRunId) || cancelIfLocked()) return; + + runs = targets.flatMap((target, index) => + buildRun(target, snapshots[index], outputs[index]) + ); + + // No segment came back with anything to write: every mask was deleted + // while the algorithm ran, or the algorithm had nothing to do to any of + // them. There is then nothing to preview and nothing to roll back. + if (runs.length === 0) { + warnIfNothingProcessed(processType, outputs); + resetState(); + paintStore.restoreModeAfterProcess(); + return; + } + + // Masked against what the segments hold now, so a run that reaches a + // voxel an earlier run of the same pass just filled leaves it there. + runs.forEach((run) => { + run.target.voxels.ensureContains(run.extent); + maskVoxelsOtherSegmentsHold(run); + run.target.voxels.apply(run.processedScalars); + }); + + processState.value = { + step: 'previewing', + ...targetedState, + runs, + showingOriginal: false, + }; + } catch (error) { + if (runIsStale(processRunId)) return; + + messageStore.addError(`${processType} Operation Failed`, { + error: error as Error, + }); + // Only the masks this run wrote are restored. A target the run never + // built a run for still holds exactly what the user left there, and + // writing its snapshot back would invalidate its renderer for nothing. + targets.forEach((target, index) => { + const run = runs.find((candidate) => candidate.target === target); + if (!run) return; + const binding = segmentationStore.findMaskBinding(target.maskId); + const grown = binding?.extent.every( + (value, axis) => value === run.extent[axis] + ); + writeIfPresent( + target.voxels, + grown ? run.originalScalars : snapshots[index] + ); + }); + resetState(); + paintStore.restoreModeAfterProcess(); + } + } + + /** + * Shows the original or the processed result, stated rather than flipped: + * the buttons that offer the two name the one they show, so re-clicking the + * one already showing has to leave the preview alone. + */ + function setShowingOriginal(showOriginal: boolean) { + if (cancelIfLocked()) return; + const state = processState.value; + + if (state.step === 'previewing' && state.showingOriginal !== showOriginal) { + state.runs.forEach((run) => + writeIfPresent( + run.target.voxels, + showOriginal ? run.originalScalars : run.processedScalars + ) + ); + + processState.value = { + ...state, + showingOriginal: showOriginal, + }; + } + } + + watch( + () => paintStore.activeMode, + (mode, previousMode) => { + if (previousMode !== PaintMode.Process || mode === PaintMode.Process) { + return; + } + if (!runInFlight()) return; + cancelProcess(); + } + ); + + // A preview belongs to the paint tool: putting the brush down hands the + // segment to another tool, which is free to grow the mask the preview holds + // a snapshot of. + watch( + () => paintStore.isActive, + (isActive) => { + if (isActive || !runInFlight()) return; + cancelProcess(); + } + ); + + // A preview holds a snapshot of storage another action can delete under it, + // so it does not outlive what it would write back into. Storage is its own + // matter: an all-segments run watches no segment, and a segment-scoped one is + // not the only way to lose a mask. + const previewStorageGone = computed(() => { + const state = processState.value; + if (state.step !== 'previewing') return false; + return state.runs.some((run) => !run.target.voxels.exists()); + }); + + watch(previewStorageGone, (gone) => { + if (gone) cancelProcess(); + }); + + // A segment-scoped run belongs to the type it was started on, so selecting + // another throws it away. An all-segments run watches nothing and outlives + // the selection changing under it. + watch( + () => segmentRegistry.selectedSegmentId.value, + (segmentId) => { + const state = runInFlight(); + if (!state) return; + const watched = state.watchedMaskId; + if (watched === undefined) return; + if ( + segmentationStore.maskExists(watched) && + segmentationStore.getMask(watched).segmentId === segmentId + ) + return; + cancelProcess(); + } + ); + + // Cancel process when current image changes + watch(currentImageID, (newVal) => { + const state = runInFlight(); + if (state && state.activeParentImageID !== newVal) cancelProcess(); + }); + + return { + processState, + processStep, + activeProcessType, + showingOriginal, + setActiveProcessType, + startProcess, + confirmProcess, + cancelProcess, + setShowingOriginal, + }; +}); diff --git a/src/segmentation/editing/processWorker.ts b/src/segmentation/editing/processWorker.ts new file mode 100644 index 000000000..2586a2980 --- /dev/null +++ b/src/segmentation/editing/processWorker.ts @@ -0,0 +1,93 @@ +import * as Comlink from 'comlink'; + +/** + * A process algorithm's worker, kept warm between runs. + * + * Comlink settles a call when the worker posts an answer back, and listens for + * nothing else. A worker that fails to load its module chunk, or that the + * browser kills, therefore answers nothing and the call waits forever: the + * process sits in `computing` with no error to show and no storage rolled + * back, and every later run reuses the same dead instance. The host watches + * the worker itself, so a worker that dies takes the calls in flight down with + * it and is dropped, leaving the next call to start a fresh one. + */ +export type ProcessWorkerHost = { + // `Awaited` rather than a `Promise` parameter: a Comlink method whose + // return type is a union hands back a union of promises, and the result + // type has to survive that. + // Rejects when the worker dies mid-call, so a caller has to await or catch. + call(use: (api: Comlink.Remote) => T): Promise>; + /** Drop the worker, ending the calls in flight. */ + terminate(): void; +}; + +/** Every host built here, so a cancelled run can drop the lot. */ +const hosts = new Set<{ terminate: () => void }>(); + +/** + * Ends every process worker. A job already posted to a worker cannot be + * called back: the worker runs it to the end and only then takes the next one. + * A cancelled run's jobs would therefore keep the worker busy with results + * nobody wants, and the run replacing them would wait behind that work. + */ +export function terminateProcessWorkers() { + hosts.forEach((host) => host.terminate()); +} + +function workerFailure(event: Event) { + const reported = (event as Partial).message; + return new Error( + reported || `The worker running this process reported "${event.type}".` + ); +} + +export function createProcessWorkerHost( + spawn: () => Worker +): ProcessWorkerHost { + let live: { + proxy: Comlink.Remote; + died: Promise; + discard: (reason: Error) => void; + } | null = null; + + function start() { + const worker = spawn(); + const proxy = Comlink.wrap(worker); + let end: (reason: Error) => void = () => {}; + const died = new Promise((_resolve, reject) => { + end = reject; + }); + const discard = (reason: Error) => { + // Only this worker's own end drops the cache: a later run may already + // have started its replacement. + if (live?.proxy === proxy) live = null; + worker.terminate(); + end(reason); + }; + const die = (event: Event) => discard(workerFailure(event)); + worker.addEventListener('error', die); + worker.addEventListener('messageerror', die); + // Calls in flight see the rejection through `call`; a worker dropped with + // nothing running is not a failure anyone is waiting on. + died.catch(() => undefined); + live = { proxy, died, discard }; + return live; + } + + async function call( + use: (api: Comlink.Remote) => T + ): Promise> { + const instance = live ?? start(); + return Promise.race([use(instance.proxy), instance.died]) as Promise< + Awaited + >; + } + + const host = { + call, + terminate: () => + live?.discard(new Error('The process worker was stopped.')), + }; + hosts.add(host); + return host; +} diff --git a/src/segmentation/editing/rasterizePolygon.ts b/src/segmentation/editing/rasterizePolygon.ts new file mode 100644 index 000000000..3539ed459 --- /dev/null +++ b/src/segmentation/editing/rasterizePolygon.ts @@ -0,0 +1,205 @@ +import { fillPoly } from '@thi.ng/rasterize'; +import type { IGrid2D } from '@thi.ng/api'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { TypedArray, Vector2, Vector3 } from '@kitware/vtk.js/types'; +import { containsPoint } from '@kitware/vtk.js/Common/DataModel/BoundingBox'; + +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import type { Maybe } from '@/src/types'; +import type { LPSAxis } from '@/src/types/lps'; +import { + clipExtent, + emptyExtent, + fullExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { getLPSDirections } from '@/src/utils/lps'; + +export function rasterizeTargetDisabledReason(segmentId: Maybe) { + const registry = useSegmentStore().segments; + const preferred = registry.getSegment(segmentId); + const effective = + preferred ?? + registry.selectedSegment.value ?? + registry.segmentList.value[0]; + return effective?.locked ? 'Unlock this segment to rasterize into it' : ''; +} + +/** + * The labelmap a polygon rasterizes into, absent when the record it lands in + * is locked. Rasterizing is itself an edit, so it routes through the one entry + * point that resolves and creates masks: a polygon carrying no segment, or one + * whose segment was deleted, lands in the selected segment rather than failing. + */ +export function resolveRasterizeTarget( + imageId: string, + segmentId: Maybe +) { + const segmentationStore = useSegmentationStore(); + + // A locked segment is not editable, the same refusal paint and the processes + // make. Asked of the segment before the target is resolved, since resolving + // mints the mask record and its segmentation: a refused polygon leaves + // neither behind. A locked neighbour is a different rule and keeps the voxels + // a fill claims, which an aimed `voxelClaim` already honours. + if (segmentationStore.editTargetLocked(segmentId)) { + useMessageStore().addError('Cannot rasterize into a locked segment'); + return undefined; + } + + const resolved = segmentationStore.resolveEditTarget(imageId, segmentId); + const voxels = segmentationStore.maskVoxels(resolved); + // The binding's extent goes stale the moment the fill grows the mask, so + // the accessor is what travels, not anything read off it now. + voxels.materialize(); + return { + labelValue: SEGMENT_VALUE, + voxels, + maskId: resolved, + segmentId: segmentationStore.getMask(resolved).segmentId, + }; +} + +/** A grid over the parent's index space, writing into the mask's own buffer. */ +function createGridAccessor( + parent: vtkImageData, + mask: { image: vtkImageData; pixelData: TypedArray; extent: Extent3D }, + plane: { slice: number; axisIdx: 0 | 1 | 2 }, // i/j/k + onFilled: (ijk: Vector3) => void +): IGrid2D { + const { slice, axisIdx } = plane; + const { extent } = mask; + const axisDims = parent.getDimensions(); + axisDims.splice(axisIdx, 1); + const convertTo3D = (a: number, b: number) => { + const point = [a, b]; + point.splice(axisIdx, 0, slice); + return point as Vector3; + }; + + return { + size: axisDims, + setAtUnsafe(d0: number, d1: number, value: number): boolean { + const ijk = convertTo3D(d0, d1); + if (containsPoint(extent, ...ijk)) { + const offset = mask.image.computeOffsetIndex([ + ijk[0] - extent[0], + ijk[1] - extent[2], + ijk[2] - extent[4], + ]); + // XXX assumes single-component image + mask.pixelData[offset] = value; + onFilled(ijk); + return true; + } + return false; + }, + } as unknown as IGrid2D; +} + +/** The box the polygon spans on its slice, in parent index space. */ +function polygonBounds( + indexPoints: number[][], + axisIndex: 0 | 1 | 2, + slice: number +): Extent3D { + if (indexPoints.length === 0) return emptyExtent(); + const bounds = [0, 0, 0, 0, 0, 0] as Extent3D; + [0, 1, 2].forEach((axis) => { + if (axis === axisIndex) { + bounds[axis * 2] = slice; + bounds[axis * 2 + 1] = slice; + return; + } + const values = indexPoints.map((point) => point[axis]); + bounds[axis * 2] = Math.floor(Math.min(...values)); + bounds[axis * 2 + 1] = Math.ceil(Math.max(...values)); + }); + return bounds; +} + +/** + * Fills a polygon into its segment's mask. The write lives here rather than in + * the tool component because it is a voxel operation: the mask has to grow to + * hold the polygon before `fillPoly` runs, since a mask that does not reach a + * pixel swallows it silently, and the filled voxels have to be cleared in the + * other segments of the image. World points, parent slice index. Edit target resolution cancels any competing preview before storage changes. + */ +export function rasterizePolygon({ + imageId, + segmentId, + points, + slice, + viewAxis, +}: { + imageId: string; + segmentId: Maybe; + points: Vector3[]; + slice: number; + viewAxis: LPSAxis; +}) { + const segmentationStore = useSegmentationStore(); + const parent = useImageCacheStore().getVtkImageData(imageId); + if (!parent) throw new Error('No such parent image'); + + const axisIndex = getLPSDirections(parent.getDirection())[viewAxis]; + const indexPoints = points.map((point) => [...parent.worldToIndex(point)]); + + // The part of the image the polygon lands on: what the mask has to grow to + // hold, and the only place this fill can take a voxel from a neighbour. + // Asked before the target is resolved, since resolving mints the mask record + // and its storage: a polygon covering nothing leaves neither behind. + const polygonExtent = clipExtent( + polygonBounds(indexPoints, axisIndex, slice), + fullExtent(parent.getDimensions()) + ); + if (isEmptyExtent(polygonExtent)) return { segmentId, maskId: undefined }; + + // A refusal names the segment it was given and no mask: nothing was written. + const target = resolveRasterizeTarget(imageId, segmentId); + if (!target) return { segmentId, maskId: undefined }; + + target.voxels.ensureContains(polygonExtent); + + // Copied out of the reactive tree: the claim below runs per filled pixel. + const extent = [...target.voxels.binding()!.extent] as Extent3D; + if (isEmptyExtent(extent)) + return { segmentId: target.segmentId, maskId: target.maskId }; + // Scan conversion stays in parent coordinates: `fillPoly` rounds its edge + // intersections by magnitude, so translating first would tie the pixels a + // polygon fills to where the mask happens to be allocated. + const points2D = indexPoints.map((point) => { + const inPlane = [...point]; + inPlane.splice(axisIndex, 1); + return inPlane as Vector2; + }); + + // A polygon is aimed at a place, so filling it takes the voxel. Scoped to + // the polygon rather than the whole mask: a neighbour the polygon does not + // reach has nothing here to give up, and it would be walked per filled pixel. + const claimVoxel = segmentationStore.voxelClaim( + target.maskId, + 'aimed', + polygonExtent + ); + const mask = target.voxels.image(); + const grid = createGridAccessor( + parent, + { image: mask, pixelData: target.voxels.scalars(), extent }, + { slice, axisIdx: axisIndex }, + (ijk) => claimVoxel?.claim(ijk[0], ijk[1], ijk[2]) + ); + + try { + fillPoly(grid, points2D, target.labelValue); + } finally { + claimVoxel?.finish(); + mask.modified(); + } + return { segmentId: target.segmentId, maskId: target.maskId }; +} diff --git a/src/segmentation/io/composition.ts b/src/segmentation/io/composition.ts new file mode 100644 index 000000000..b99af741e --- /dev/null +++ b/src/segmentation/io/composition.ts @@ -0,0 +1,101 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { allocateMask } from '@/src/segmentation/masks/storage'; +import { + boundScalars, + groupByLayer, + writeMaskInto, +} from '@/src/segmentation/masks/overlap'; +import { + LABELMAP_MAX_VALUE, + nextUnusedLabelValue, +} from '@/src/segmentation/masks/labelValue'; +import { + maskScalars, + type SegmentMask, + type LabelmapBinding, + type LabelmapSegment, +} from '@/src/segmentation/model'; +import { fullExtent } from '@/src/segmentation/geometry'; +import { toLabelmapSegment } from '@/src/segmentation/segment'; + +const boundedMask = (binding?: LabelmapBinding) => + binding && boundScalars(binding.image, binding.extent); + +/** + * The given segments as one parent-shaped labelmap, built on demand and never + * stored: what leaves VolView means the whole segmentation, not one segment's + * bounded mask. Earlier in the registry wins where two segments overlap, which + * is the order their actors stack in, so the flattened file resolves an + * overlap the way the screen did. `members` defaults to the image's segments; + * an export passes one group so no overlap is flattened away. + */ +export function compositeLabelmap( + parentImageId: string, + members?: SegmentMask[] +) { + useSegmentationEditsStore().beforeRead(); + const imageCacheStore = useImageCacheStore(); + const segmentRegistry = useSegmentStore().segments; + const imageMasks = useSegmentationStore().imageMasks; + const parent = imageCacheStore.getVtkImageData(parentImageId); + if (!parent) throw new Error('No such parent image'); + + const dimensions = parent.getDimensions(); + const labelmap = allocateMask(parent, fullExtent(dimensions)); + const values = maskScalars(labelmap); + + const included = [...(members ?? imageMasks(parentImageId))].sort( + (first, second) => + segmentRegistry.orderIndexOf(first.segmentId) - + segmentRegistry.orderIndexOf(second.segmentId) + ); + // One file carries one label per voxel, so the values are assigned here + // rather than read off the masks, which all hold SEGMENT_VALUE. Callers + // pass a group layeredSegments already sized to fit them. + const used = new Set(); + const segments: LabelmapSegment[] = []; + included.forEach((segment) => { + const labelValue = nextUnusedLabelValue(used, LABELMAP_MAX_VALUE); + used.add(labelValue); + segments.push( + toLabelmapSegment( + segmentRegistry.getSegment(segment.segmentId), + labelValue + ) + ); + }); + [...included].reverse().forEach((segment, index) => { + const bounded = boundedMask(segment.representations.labelmap); + const labelValue = segments[included.length - 1 - index].value; + if (bounded) writeMaskInto(values, dimensions, bounded, labelValue); + }); + + return { labelmap, segments }; +} + +/** + * The image's segments grouped so no group holds an overlap. A labelmap file + * carries one label per voxel, so an export writes a file per group. Always + * at least one group: an image with no segments still exports one file. + */ +export function layeredSegments(parentImageId: string) { + const groups = groupByLayer( + useSegmentationStore().imageMasks(parentImageId), + (segment) => boundedMask(segment.representations.labelmap) + ); + // One byte per voxel caps a file's segments however little they overlap, + // so a group past the cap is split into files that fit. + const sized = groups.flatMap((group) => + group.length <= LABELMAP_MAX_VALUE + ? [group] + : Array.from( + { length: Math.ceil(group.length / LABELMAP_MAX_VALUE) }, + (_, n) => + group.slice(n * LABELMAP_MAX_VALUE, (n + 1) * LABELMAP_MAX_VALUE) + ) + ); + return sized.length ? sized : [[]]; +} diff --git a/src/segmentation/io/import.ts b/src/segmentation/io/import.ts new file mode 100644 index 000000000..0034c6ecf --- /dev/null +++ b/src/segmentation/io/import.ts @@ -0,0 +1,346 @@ +import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { RGBAColor, TypedArray } from '@kitware/vtk.js/types'; + +import { untilLoaded } from '@/src/composables/untilLoaded'; +import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; +import { ensureSameSpace } from '@/src/io/resample/resample'; +import { + overlaySegmentMetadata, + parseSegNrrdMetadata, +} from '@/src/io/segNrrdMetadata'; +import { useDICOMStore } from '@/src/store/datasets-dicom'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { + LABELMAP_BACKGROUND_VALUE, + makeDefaultSegmentName, + maskScalars, + type LabelmapSegment, +} from '@/src/segmentation/model'; +import { + emptyExtent, + extentSize, + isEmptyExtent, + maskOffset, + type Extent3D, + growExtent, +} from '@/src/segmentation/geometry'; +import { + type DataSelection, + getImage, + isRegularImage, +} from '@/src/utils/dataSelection'; +import vtkImageExtractComponents from '@/src/utils/imageExtractComponentsFilter'; +import vtkLabelMap from '@/src/vtk/LabelMap'; + +const LabelmapArrayType = Uint8Array; + +export type ImportedSegment = { sourceValue: number; maskId: string }; + +function convertToUint8(array: number[] | TypedArray): Uint8Array { + const uint8Array = new Uint8Array(array.length); + for (let i = 0; i < array.length; i++) { + const value = array[i]; + uint8Array[i] = value < 0 || value > 255 ? 0 : value; + } + return uint8Array; +} + +function getLabelMapScalars(imageData: vtkImageData) { + const scalars = imageData.getPointData().getScalars(); + let values = scalars.getData(); + + if (!(values instanceof LabelmapArrayType)) { + values = convertToUint8(values); + } + + return vtkDataArray.newInstance({ + numberOfComponents: scalars.getNumberOfComponents(), + values, + }); +} + +export function toLabelMap(imageData: vtkImageData) { + const labelmap = vtkLabelMap.newInstance( + imageData.get('spacing', 'origin', 'direction', 'extent', 'dataDescription') + ); + + labelmap.setDimensions(imageData.getDimensions()); + labelmap.computeTransforms(); + + // outline rendering only supports UInt8Array image types + const scalars = getLabelMapScalars(imageData); + labelmap.getPointData().setScalars(scalars); + + return labelmap; +} + +function extractEachComponent(input: vtkImageData) { + const numComponents = input + .getPointData() + .getScalars() + .getNumberOfComponents(); + const extractComponentsFilter = vtkImageExtractComponents.newInstance(); + extractComponentsFilter.setInputData(input); + return Array.from({ length: numComponents }, (_, i) => { + extractComponentsFilter.setComponents([i]); + extractComponentsFilter.update(); + return extractComponentsFilter.getOutputData() as vtkImageData; + }); +} + +// The decode and the split both need this sweep of the same buffer, and it is +// the whole parent volume, so the result rides along until the buffer changes. +const boundsCache = new WeakMap< + vtkLabelMap, + { mTime: number; bounds: Map } +>(); + +function labelValueBounds(labelmap: vtkLabelMap) { + const cached = boundsCache.get(labelmap); + if (cached?.mTime === labelmap.getMTime()) return cached.bounds; + + const scalars = maskScalars(labelmap); + const [di, dj, dk] = labelmap.getDimensions(); + const bounds = new Map(); + + const scanRow = (rowStart: number, j: number, k: number) => { + for (let i = 0; i < di; i += 1) { + const value = scalars[rowStart + i]; + if (value === LABELMAP_BACKGROUND_VALUE) continue; + const box = bounds.get(value); + if (box) growExtent(box, i, j, k); + else bounds.set(value, [i, i, j, j, k, k]); + } + }; + + for (let k = 0; k < dk; k += 1) + for (let j = 0; j < dj; j += 1) scanRow((j + k * dj) * di, j, k); + + boundsCache.set(labelmap, { mTime: labelmap.getMTime(), bounds }); + return bounds; +} + +type LabelmapSweep = { + scalars: Uint8Array; + dimensions: number[] | Int32Array; + value: number; +}; + +/** Copies one label value's voxels into `mask`, rewritten to `labelValue`. */ +function cropLabelValue( + sweep: LabelmapSweep, + extent: Extent3D, + mask: Uint8Array, + labelValue: number +) { + const [di, dj] = sweep.dimensions; + const [mi, mj] = extentSize(extent); + const bounds = { extent, mi, mj }; + + const copyRow = (j: number, k: number) => { + const sourceStart = (j + k * dj) * di; + const maskStart = maskOffset(bounds, extent[0], j, k); + for (let i = extent[0]; i <= extent[1]; i += 1) { + if (sweep.scalars[sourceStart + i] !== sweep.value) continue; + mask[maskStart + i - extent[0]] = labelValue; + } + }; + + for (let k = extent[4]; k <= extent[5]; k += 1) + for (let j = extent[2]; j <= extent[3]; j += 1) copyRow(j, k); +} + +export type MaskMinter = ( + descriptor: LabelmapSegment, + extent: Extent3D +) => { labelValue: number; mask: Uint8Array }; + +/** + * Each descriptor gets a mask cropped to the box its value's voxels span, + * filled with the value the minter assigned it. + */ +export function splitLabelmap( + labelmap: vtkLabelMap, + descriptors: LabelmapSegment[], + mint: MaskMinter +) { + const scalars = maskScalars(labelmap); + const dimensions = labelmap.getDimensions(); + const bounds = labelValueBounds(labelmap); + + descriptors.forEach((descriptor) => { + const extent = bounds.get(descriptor.value) ?? emptyExtent(); + const { labelValue, mask } = mint(descriptor, extent); + if (isEmptyExtent(extent)) return; + cropLabelValue( + { scalars, dimensions, value: descriptor.value }, + extent, + mask, + labelValue + ); + }); +} + +/** DICOM-SEG carries its own catalog; anything else has to be derived. */ +async function segBuildDescriptors( + imageId: DataSelection | undefined, + component: number +) { + if (imageId === undefined || isRegularImage(imageId)) return undefined; + if (useDICOMStore().volumeInfo[imageId]?.kind === 'cine') return undefined; + + await untilLoaded(imageId); + const chunkImage = useImageCacheStore().imageById[imageId] as DicomChunkImage; + if (chunkImage.getModality() !== 'SEG' || !chunkImage.segBuildInfo) + return undefined; + + return chunkImage.segBuildInfo.segmentAttributes[component].map( + (segment) => ({ + value: segment.labelID, + name: segment.SegmentLabel, + color: [...segment.recommendedDisplayRGBValue, 255] as RGBAColor, + visible: true, + }) + ); +} + +const distinctLabelValues = (image: vtkLabelMap) => + [...labelValueBounds(image).keys()].sort((first, second) => first - second); + +export type DecodeOptions = { + /** Which component of a multi-component DICOM-SEG to read descriptors from. */ + component?: number; + /** File-header metadata, for bytes that never came through a loaded image. */ + headerMetadata?: Map; + /** What undescribed segments are named after, in place of 'Segment'. */ + baseName?: string; + nextColor: () => readonly number[]; +}; + +/** A lone value carries the base name bare: there is nothing to tell apart. */ +const fallbackNamer = (values: number[], baseName?: string) => { + if (!baseName) return makeDefaultSegmentName; + if (values.length === 1) return () => baseName; + return (value: number) => `${baseName} ${value}`; +}; + +/** + * `imageId` may be undefined when the labelmap's bytes did not arrive through + * a loaded image dataset. DICOM-SEG decoding still + * requires a source image, while file-header metadata can be supplied directly + * for archive-backed images. + */ +export async function decodeLabelmapSegments( + imageId: DataSelection | undefined, + image: vtkLabelMap, + options: DecodeOptions +) { + const fromSegBuild = await segBuildDescriptors( + imageId, + options.component ?? 0 + ); + if (fromSegBuild) return fromSegBuild; + + // Slicer-convention `.seg.nrrd` embedded metadata: a labelmap produced by a + // backend CLI carries its real segment names/colors in the NRRD header, + // captured onto the loaded image at import. + // + // Overlay metadata so undescribed voxel values retain a default segment. + const embedded = + options.headerMetadata ?? + (imageId !== undefined + ? useImageCacheStore().imageById[imageId]?.headerMetadata + : undefined); + const described = embedded ? parseSegNrrdMetadata(embedded) : undefined; + + const values = distinctLabelValues(image); + const nameFor = fallbackNamer(values, options.baseName); + + return overlaySegmentMetadata(values, described, (value) => ({ + value, + name: nameFor(value), + color: [...options.nextColor()] as RGBAColor, + visible: true, + })); +} + +export type LabelmapImportHooks = { + decode: ( + labelmap: vtkLabelMap, + component: number + ) => Promise; + /** Mints the segments for one decoded labelmap, in descriptor order. */ + split: (labelmap: vtkLabelMap, descriptors: LabelmapSegment[]) => string[]; +}; + +/** + * Resampling and decoding both yield, and an image can be removed while they + * run, so the parent is resolved through the cache again after every await: + * nothing may be decoded or minted against an image that left the scene. + */ +function requireParentImage(parentID: DataSelection) { + const parentImage = getImage(parentID); + if (!parentImage) throw new Error('Parent image is no longer loaded'); + return parentImage; +} + +/** + * Returns the segments created per component of the source image (one entry + * for the common single-component case), each paired with the source label + * value it was split from. A value already taken on the parent is remapped, so + * the source value is the only handle a caller's descriptors can match on. + */ +export async function importLabelmapImage( + imageID: DataSelection, + parentID: DataSelection, + hooks: LabelmapImportHooks +): Promise { + if (imageID === parentID) + throw new Error('Cannot convert an image to be a labelmap of itself'); + + await untilLoaded(imageID); + + const [childImage, parentImage] = await Promise.all( + [imageID, parentID].map(getImage) + ); + + if (!childImage || !parentImage) + throw new Error('Image and/or parent datasets do not exist'); + + const intersects = vtkBoundingBox.intersects( + parentImage.getBounds(), + childImage.getBounds() + ); + if (!intersects) { + throw new Error( + 'Imported image and parent image bounds do not intersect. So there is no overlap in physical space.' + ); + } + + const componentCount = childImage + .getPointData() + .getScalars() + .getNumberOfComponents(); + const images = + componentCount === 1 ? [childImage] : extractEachComponent(childImage); + + // Sequential, not fanned out: the splits share one segmentation, and label + // values are minted against the segments already in it. + const created: ImportedSegment[][] = []; + for (const [component, image] of images.entries()) { + const matchingParentSpace = await ensureSameSpace(parentImage, image, true); + requireParentImage(parentID); + const labelmapImage = toLabelMap(matchingParentSpace); + const descriptors = await hooks.decode(labelmapImage, component); + requireParentImage(parentID); + created.push( + hooks.split(labelmapImage, descriptors).map((maskId, index) => ({ + sourceValue: descriptors[index].value, + maskId, + })) + ); + } + return created; +} diff --git a/src/segmentation/io/maskFileNaming.ts b/src/segmentation/io/maskFileNaming.ts index 873b4b76a..a9e01121e 100644 --- a/src/segmentation/io/maskFileNaming.ts +++ b/src/segmentation/io/maskFileNaming.ts @@ -6,7 +6,9 @@ const defaultName = (baseName: string, index: number) => * count keeps rising so a deleted mask's name is not immediately handed to * the next one, and `taken` skips a name something already holds. */ -export function createMaskFileNamer(taken: () => Set) { +export function createMaskFileNamer( + taken: () => { has: (name: string) => boolean } +) { const nextIndex: Record = Object.create(null); return { pick(parentImageId: string, baseName: string) { diff --git a/src/segmentation/io/restore.ts b/src/segmentation/io/restore.ts new file mode 100644 index 000000000..45da29501 --- /dev/null +++ b/src/segmentation/io/restore.ts @@ -0,0 +1,150 @@ +import { markRaw } from 'vue'; +import { until } from '@vueuse/core'; +import type { ProgressiveImage } from '@/src/core/progressiveImage'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import type { Segmentation } from '@/src/io/state-file/schema'; +import { placeMask, setMaskScalars } from '@/src/segmentation/masks/storage'; +import type { ProcessingResultSource } from '@/src/types'; +import { + LABELMAP_BACKGROUND_VALUE, + maskScalars, + type LabelmapBinding, +} from '@/src/segmentation/model'; +import { + extentContains, + extentSize, + fullExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { arrayEquals } from '@/src/utils'; +import type vtkLabelMap from '@/src/vtk/LabelMap'; + +export type WireMask = Segmentation['masks'][number]; + +export function createLoadedImageReader( + getImage: (id: string) => ProgressiveImage | undefined, + getVtkImageData: (id: string) => vtkImageData | undefined +) { + return async (imageId: string) => { + // A stopped, incomplete load cannot supply the grid for an input. + // Removal also settles the watcher, including removal before it starts. + await until(() => !getImage(imageId)?.loading.value).toBe(true); + if (getImage(imageId)?.status.value !== 'complete') { + throw new Error('Labelmap image did not load'); + } + const image = getVtkImageData(imageId); + if (!image) throw new Error('Could not get input image data'); + return image; + }; +} + +export type LoadedLabelmap = { + labelmap: vtkLabelMap; + name: string; + source?: ProcessingResultSource; +}; + +export type SkippedRestoreItem = { name: string; reason: string }; + +type RestoreBindingInput = { + manifest: { segmentations?: Segmentation[] }; + dataIDMap: Record; + /** + * What each mask's own archive entry held. A mask awaiting an input's + * split is absent: its voxels are still inside that input. + */ + loaded: Map; + getParentImage: (id: string) => vtkImageData | undefined; +}; + +const sameDimensions = (extent: Extent3D, dimensions: number[]) => + arrayEquals(extentSize(extent), dimensions); + +function validExtent( + extent: Extent3D, + labelmap: vtkLabelMap, + parentImage: vtkImageData, + reject: (reason: string) => void +) { + if (isEmptyExtent(extent)) { + const containsForeground = maskScalars(labelmap).some( + (value) => value !== LABELMAP_BACKGROUND_VALUE + ); + if (!containsForeground) return true; + reject('empty extent references a mask with foreground voxels'); + return false; + } + + if (!sameDimensions(extent, labelmap.getDimensions())) { + reject('extent does not match the loaded mask dimensions'); + return false; + } + if (!extentContains(fullExtent(parentImage.getDimensions()), extent)) { + reject('extent leaves the parent image'); + return false; + } + return true; +} + +/** + * Places each loaded mask on its parent's grid at the bounds its binding + * claims, refusing bounds the labelmap or the image does not support. Nothing + * is shared: a mask that fails validation leaves every other mask alone, + * because each one was read into a buffer of its own. + */ +export function prepareRestoreBindings(input: RestoreBindingInput) { + const { manifest, dataIDMap, loaded, getParentImage } = input; + const acceptedBindings = new WeakMap(); + const skipped: SkippedRestoreItem[] = []; + + const place = (wireMask: WireMask, parentImage: vtkImageData | undefined) => { + const wireBinding = wireMask.representations.labelmap; + const available = loaded.get(wireMask); + if (!wireBinding || !available) return; + + const { name, labelmap, source } = available; + const reject = (reason: string) => skipped.push({ name, reason }); + + if (!parentImage) { + reject('parent image data is unavailable'); + return; + } + + const extent = [...wireBinding.extent] as Extent3D; + if (!validExtent(extent, labelmap, parentImage, reject)) return; + + placeMask(labelmap, parentImage, extent); + if (isEmptyExtent(extent)) setMaskScalars(labelmap, new Uint8Array(0)); + acceptedBindings.set(wireMask, { + image: markRaw(labelmap), + extent, + name, + ...(source ? { source } : {}), + }); + }; + + (manifest.segmentations ?? []).forEach((wire) => { + const parentImageId = dataIDMap[wire.parentImage]; + // A parent the restore never mapped is as unavailable as one whose data + // did not load, and reports the same way rather than dropping its masks in + // silence: losing masks must read differently from having none. + const parentImage = + parentImageId === undefined ? undefined : getParentImage(parentImageId); + orderedWireMasks(wire).forEach((wireMask) => place(wireMask, parentImage)); + }); + + return { acceptedBindings, skipped }; +} + +export function orderedWireMasks(wire: { + masks: T[]; + order: string[]; +}) { + const byId = new Map(wire.masks.map((mask) => [mask.id, mask])); + return wire.order.flatMap((maskId) => { + const mask = byId.get(maskId); + return mask ? [mask] : []; + }); +} diff --git a/src/segmentation/io/stateFile.ts b/src/segmentation/io/stateFile.ts new file mode 100644 index 000000000..f32cc5f46 --- /dev/null +++ b/src/segmentation/io/stateFile.ts @@ -0,0 +1,610 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import type { Ref, ComputedRef } from 'vue'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import vtkLabelMap from '@/src/vtk/LabelMap'; +import { allocateMask } from '@/src/segmentation/masks/storage'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { + createLoadedImageReader, + orderedWireMasks, + prepareRestoreBindings, + type LoadedLabelmap, + type WireMask, +} from '@/src/segmentation/io/restore'; +import { readImage, writeSegmentation } from '@/src/io/readWriteImage'; +import { + planLabelmapImports, + type LabelmapImport, + type LabelmapRestoreSource, +} from '@/src/io/import/labelmapImports'; +import type { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { makeMaskArchivePath } from '@/src/io/state-file/maskArchivePath'; +import type { FileEntry } from '@/src/io/types'; +import type { Maybe, ProcessingResultSource } from '@/src/types'; +import { toLabelmapSegment } from '@/src/segmentation/segment'; +import { cleanUndefined } from '@/src/utils'; +import { normalize } from '@/src/utils/path'; +import { splitLabelmap, toLabelMap } from '@/src/segmentation/io/import'; +import { ensureSameSpace } from '@/src/io/resample/resample'; +import { useDatasetStore } from '@/src/store/datasets'; +import { + listMasks, + maskScalars, + type LabelmapBinding, + type LabelmapSegment, + type SegmentMask, + type Segmentation, +} from '@/src/segmentation/model'; +import { type Extent3D } from '@/src/segmentation/geometry'; + +import type { useImageCacheStore } from '@/src/store/image-cache'; +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import type { DataSelection } from '@/src/utils/dataSelection'; + +/** + * The labelmap codec the state file writes through. Injected because itk-wasm + * and the vti worker have no node counterpart. + */ +export type LabelmapIO = { + write: ( + format: string, + labelmap: vtkLabelMap, + segments: LabelmapSegment[] + ) => Promise; + read: ( + file: File + ) => Promise<{ image: vtkImageData; headerMetadata?: Map }>; +}; + +// ZIP entries are relative; extraction may prefix a root member with a slash. +const archivePathKey = (path: string) => normalize(path).replace(/^\/+/, ''); + +const defaultLabelmapIO: LabelmapIO = { + write: writeSegmentation, + read: readImage, +}; + +/** + * Each mask is its own codec call and each codec call is its own worker, so a + * scene with many masks would start one worker per mask and hold every parsed + * mask at once. Save and restore run this many at a time instead. + */ +export const MASK_IO_CONCURRENCY = 4; + +/** Promise.all with a bound on how many run at once; results stay in order. */ +async function mapWithLimit( + items: T[], + limit: number, + run: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await run(items[index]); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker) + ); + return results; +} + +export type SegmentationWireDeps = { + segmentations: Record; + saveFormat: Ref; + imageCacheStore: ReturnType; + segmentRegistry: SegmentRegistry; + labelmapDescriptorByMask: ComputedRef>; + createMask: (segmentationId: string, segmentId: string) => SegmentMask; + createBindingForImage: ( + parentImageId: string, + extent: Extent3D, + source?: ProcessingResultSource, + name?: string + ) => LabelmapBinding; + attachMaskBinding: ( + maskId: string, + binding: LabelmapBinding + ) => LabelmapBinding; + decodeSegments: ( + imageId: DataSelection | undefined, + image: vtkLabelMap, + options?: { component?: number; headerMetadata?: Map } + ) => Promise & { color: number[] }>>; + ensureSegmentationForImage: (parentImageId: string) => Segmentation; + getSegmentationForImage: (parentImageId: string) => Segmentation | undefined; + maskFor: ( + imageId: Maybe, + segmentId: Maybe + ) => SegmentMask | undefined; + splitLabelmapIntoMasks: ( + parentImageId: string, + labelmap: vtkLabelMap, + descriptors: LabelmapSegment[], + options?: { + source?: ProcessingResultSource; + name?: string; + ownSegments?: boolean; + } + ) => SegmentMask[]; +}; + +export type DeserializeOptions = { + manifest: Manifest; + stateFiles: FileEntry[]; + dataIDMap: Record; + /** Ids the registry minted for the incoming segments, keyed by wire id. */ + segmentIdMap?: Record; + /** + * Per-import restore source, resolved by the restore setup (see + * resolveLabelmapSources in labelmapImports.ts, the single owner of + * the synthesized-leaf and ownership policy). Mapped through dataIDMap here. + */ + labelmapSources?: Record; + io?: LabelmapIO; +}; + +/** + * The state-file half of the segmentation store: what a scene writes to an + * archive and what a restore reads back. Split out so the store itself holds + * the records, and given the store's own accessors rather than reaching for + * them, which keeps the invariants in one place. + */ +export function createSegmentationWire(deps: SegmentationWireDeps) { + const { + segmentations, + saveFormat, + imageCacheStore, + segmentRegistry, + labelmapDescriptorByMask, + createMask, + createBindingForImage, + attachMaskBinding, + decodeSegments, + ensureSegmentationForImage, + getSegmentationForImage, + maskFor, + splitLabelmapIntoMasks, + } = deps; + + /** + * A mask that covers nothing holds no voxels, and an image codec has nothing + * to write; the binding's empty extent is what restores it, so one background + * voxel stands in for the bytes. + */ + function writableMask(parentImageId: string, binding: LabelmapBinding) { + if (binding.image.getDimensions().every((size) => size > 0)) + return binding.image; + const parent = imageCacheStore.getVtkImageData(parentImageId); + return parent ? allocateMask(parent, [0, 0, 0, 0, 0, 0]) : binding.image; + } + + async function serialize( + state: StateFile, + io: LabelmapIO = defaultLabelmapIO + ) { + useSegmentationEditsStore().beforeRead(); + const { zip, manifest } = state; + const format = saveFormat.value; + const usedArchivePaths = new Set(); + + // One archive entry per bound mask, named on the binding itself: a + // labelmap holds that segment's voxels and no other's. + const pathOf = new Map(); + const entries = Object.values(segmentations).flatMap((segmentation) => + listMasks(segmentation).flatMap((segment) => { + const binding = segment.representations.labelmap; + if (!binding) return []; + const path = makeMaskArchivePath( + binding.name, + format, + usedArchivePaths + ); + pathOf.set(segment.id, path); + return [ + { + maskId: segment.id, + parentImageId: segmentation.parentImageId, + binding, + path, + }, + ]; + }) + ); + + delete manifest.segmentationArtifacts; + + // The wire binding each write has to restate its extent on, by mask id. + const wireBindings = new Map(); + + manifest.segmentations = Object.values(segmentations).map( + (segmentation) => ({ + id: segmentation.id, + name: segmentation.name, + parentImage: segmentation.parentImageId, + fillOpacity: segmentation.fillOpacity, + outlineOpacity: segmentation.outlineOpacity, + outlineThickness: segmentation.outlineThickness, + masks: listMasks(segmentation).map((segment) => { + const binding = segment.representations.labelmap; + const labelmap = binding && { + extent: [...binding.extent] as Extent3D, + path: pathOf.get(segment.id)!, + name: binding.name, + ...(binding.source ? { source: binding.source } : {}), + }; + if (labelmap) wireBindings.set(segment.id, labelmap); + return { + id: segment.id, + segmentId: segment.segmentId, + representations: labelmap ? { labelmap } : {}, + }; + }), + order: [...segmentation.order], + }) + ); + + await mapWithLimit( + entries, + MASK_IO_CONCURRENCY, + async ({ maskId, parentImageId, binding, path }) => { + // An edit can grow a mask still queued for its write, and growth keeps + // the image instance, so the bounds the codec is handed are only known + // here: restated in the same tick as the pixels the codec copies. + const image = writableMask(parentImageId, binding); + wireBindings.get(maskId)!.extent = [...binding.extent] as Extent3D; + zip.file( + path, + await io.write(format, image, [ + labelmapDescriptorByMask.value[maskId], + ]) + ); + } + ); + } + + async function deserialize({ + manifest: incoming, + stateFiles, + dataIDMap, + segmentIdMap = {}, + labelmapSources = {}, + io = defaultLabelmapIO, + }: DeserializeOptions) { + const { imports, segmentations: wireSegmentations } = + planLabelmapImports(incoming); + const manifest = { segmentations: wireSegmentations }; + // Prototype-free: a file's mask ids are its own, so one spelling an + // Object.prototype key ('__proto__', 'constructor') must record like any + // other id rather than reaching an inherited member. + const maskIdMap: Record = Object.create(null); + // Which items reached the scene, by wire id. Each lands as one mask + // per segment and so has no single store id of its own. + const restoredImportIds = new Set(); + // Non-silent drops: every labelmap left out of the restore is recorded + // with a concrete reason so the caller can surface it. + const skipped: Array<{ name: string; reason: string }> = []; + + // A path-less item's store id: the restore setup already resolved which + // STATE id carries its bytes; this only maps that id through dataIDMap. + const sourceStoreId = (item: LabelmapImport) => { + if ('path' in item.input) return undefined; + const source = labelmapSources[item.id]; + return source !== undefined ? dataIDMap[source.stateId] : undefined; + }; + + // The archive's members, keyed once for the whole restore: every item and + // every mask names one. First entry wins on a key two members share. + const archiveMembers = new Map< + string, + (typeof stateFiles)[number]['file'] + >(); + stateFiles.forEach((entry) => { + const key = archivePathKey(entry.archivePath); + if (!archiveMembers.has(key)) archiveMembers.set(key, entry.file); + }); + const archiveMember = (path: string) => + archiveMembers.get(archivePathKey(path)); + + const sourceReads = new Map>(); + function readImport(item: LabelmapImport, storeId: string | undefined) { + const input = item.input; + const key = + 'path' in input + ? `archive:${archivePathKey(input.path)}` + : `dataset:${storeId}`; + let read = sourceReads.get(key); + if (!read) { + read = (async () => { + if ('path' in input) { + const file = archiveMember(input.path); + if (!file) throw new Error('Archive member is missing'); + return io.read(file); + } + return { + image: await loadedImage(storeId!), + headerMetadata: imageCacheStore.imageById[storeId!]?.headerMetadata, + }; + })(); + sourceReads.set(key, read); + } + return read; + } + + const loadedImage = createLoadedImageReader( + (id) => imageCacheStore.imageById[id], + (id) => imageCacheStore.getVtkImageData(id) ?? undefined + ); + + // Skip BEFORE awaiting anything an item whose parent image is + // unresolved, or a path-less one whose datasource never materialized; + // `untilLoaded(undefined)` never times out and would hang restore forever. + const attachable = imports.filter((item) => { + if (dataIDMap[item.parentImage] === undefined) { + skipped.push({ + name: item.name, + reason: 'parent image did not load', + }); + return false; + } + if ('path' in item.input) return true; + const hasImport = sourceStoreId(item) !== undefined; + if (!hasImport) { + skipped.push({ + name: item.name, + reason: 'labelmap source unavailable', + }); + } + return hasImport; + }); + + // Every path-less item's temporary imported dataset must be removed + // exactly ONCE, and only AFTER every item that reads it has settled; + // two items sharing a dataSourceId share one temp dataset id. Collected + // from EVERY item, not just the attachable ones: one skipped at the + // parent-image check may still have imported its leaf. + const tempStoreIdsToRemove = new Set( + imports + .filter((item) => labelmapSources[item.id]?.temporary === true) + .map(sourceStoreId) + .filter((storeId): storeId is string => storeId !== undefined) + ); + + let loaded; + try { + loaded = await mapWithLimit( + attachable, + MASK_IO_CONCURRENCY, + async (item) => { + const storeId = sourceStoreId(item); + try { + const { image, headerMetadata } = await readImport(item, storeId); + // Bounded conversion indexes a labelmap as single-component, so a + // multi-component one would split into shifted, truncated masks. + if (image.getPointData().getScalars().getNumberOfComponents() > 1) { + skipped.push({ + name: item.name, + reason: 'multi-component labelmap artifacts are not supported', + }); + return undefined; + } + const labelmap = toLabelMap( + await ensureSameSpace( + await loadedImage(dataIDMap[item.parentImage]), + image, + true + ) + ); + // A group that carried no descriptors is enumerated here, through + // the same decode live import uses, while its source image is + // still loaded: the temp item dataset is dropped below. + const decoded = item.decode + ? ((await decodeSegments(storeId, labelmap, { + headerMetadata, + })) as LabelmapSegment[]) + : undefined; + return { item, labelmap, decoded }; + } catch { + // A parse/read failure skips just this item and never rejects the + // whole restore; the survivors still attach. + skipped.push({ + name: item.name, + reason: 'could not read/parse labelmap', + }); + return undefined; + } + } + ); + } finally { + const datasetStore = useDatasetStore(); + tempStoreIdsToRemove.forEach((storeId) => datasetStore.remove(storeId)); + } + + // A saved mask names an archive entry of its own, read into a buffer of + // its own: masks share no storage, whatever a hand-edited manifest says. + const maskLabelmaps = new Map(); + const wireMasks = (manifest.segmentations ?? []).flatMap((wire) => + orderedWireMasks(wire) + ); + await mapWithLimit(wireMasks, MASK_IO_CONCURRENCY, async (wireMask) => { + const binding = wireMask.representations.labelmap; + if (binding?.path === undefined) return; + const name = binding.name ?? ''; + const file = archiveMember(binding.path); + if (!file) { + skipped.push({ name, reason: 'archive member is missing' }); + return; + } + try { + const { image } = await io.read(file); + maskLabelmaps.set(wireMask, { + labelmap: toLabelMap(image), + name, + ...(binding.source ? { source: binding.source } : {}), + }); + } catch { + // One unreadable mask never rejects the restore; the rest attach. + skipped.push({ name, reason: 'could not read/parse labelmap' }); + } + }); + + // Reads, resampling and decoding yield to image deletion. Recheck before + // creating any masks, after every asynchronous placement step has settled. + loaded = loaded.filter((result) => { + if (!result) return false; + if (imageCacheStore.getVtkImageData(dataIDMap[result.item.parentImage])) + return true; + skipped.push({ + name: result.item.name, + reason: 'parent image is unavailable', + }); + return false; + }); + const prepared = prepareRestoreBindings({ + manifest, + dataIDMap, + loaded: maskLabelmaps, + getParentImage: (id) => imageCacheStore.getVtkImageData(id) ?? undefined, + }); + skipped.push(...prepared.skipped); + const { acceptedBindings } = prepared; + + // Why a wire mask cannot become a record, or undefined when it can: a + // mask whose segment did not restore has no identity to show, and a + // second mask for a segment already on this image cannot exist. + const dropReason = (imageId: string, segmentId: Maybe) => { + if (!segmentId) return 'its segment is not in the file'; + if (!segmentRegistry.getSegment(segmentId)) + return 'its segment did not restore'; + if (maskFor(imageId, segmentId)) + return 'the image already has a mask for its segment'; + return undefined; + }; + + (manifest.segmentations ?? []).forEach((wire) => { + const parentImageId = dataIDMap[wire.parentImage]; + if (!imageCacheStore.getVtkImageData(parentImageId)) return; + + // An import into an image that already has masks adds to them: the + // display this scene is set to is the user's, not the incoming file's. + const existing = getSegmentationForImage(parentImageId); + const segmentation = + existing ?? ensureSegmentationForImage(parentImageId); + if (!existing) { + segmentation.name = wire.name; + segmentation.fillOpacity = wire.fillOpacity; + segmentation.outlineOpacity = wire.outlineOpacity; + segmentation.outlineThickness = wire.outlineThickness; + } + + orderedWireMasks(wire).forEach((wireMask) => { + const segmentId = segmentIdMap[wireMask.segmentId]; + const reason = dropReason(parentImageId, segmentId); + if (reason) { + skipped.push({ + name: wireMask.representations.labelmap?.name ?? '', + reason, + }); + return; + } + + const segment = createMask(segmentation.id, segmentId); + + const accepted = acceptedBindings.get(wireMask); + if (accepted) attachMaskBinding(segment.id, accepted); + maskIdMap[wireMask.id] = segment.id; + }); + }); + + // All asynchronous reads have settled. Fill the masks already placed in + // wire order; their identities, selection and tool references stay intact. + loaded.forEach((result) => { + if (!result) return; + const { item, labelmap, decoded } = result; + const parentImageId = dataIDMap[item.parentImage]; + let restored: SegmentMask[]; + if (decoded) { + const descriptors = decoded.map((descriptor) => ({ + ...descriptor, + ...cleanUndefined({ + fillOpacity: item.display.fillOpacity, + outlineOpacity: item.display.outlineOpacity, + visible: + item.display.visible === undefined + ? undefined + : descriptor.visible && item.display.visible, + }), + })); + restored = splitLabelmapIntoMasks( + parentImageId, + labelmap, + descriptors, + { + source: item.source, + name: item.name, + // Migrated groups never merge, and this one's display would be + // dropped by a same-named segment another image already minted. + ownSegments: Object.values(item.display).some( + (value) => value !== undefined + ), + } + ); + const activeIndex = descriptors.findIndex( + (descriptor) => descriptor.value === item.activeValue + ); + const active = restored[activeIndex]; + // A restore into a populated scene leaves the user's selection alone. + if (active && !segmentRegistry.selectedSegmentId.value) + segmentRegistry.selectSegment(active.segmentId); + } else { + const segmentation = getSegmentationForImage(parentImageId); + const targets = item.masks.flatMap(({ maskId, value }) => { + const mask = segmentation?.masks[maskIdMap[maskId]]; + if (!mask || !segmentRegistry.getSegment(mask.segmentId)) return []; + return [ + { + mask, + descriptor: toLabelmapSegment( + segmentRegistry.getSegment(mask.segmentId), + value + ), + }, + ]; + }); + const maskByDescriptor = new Map( + targets.map(({ mask, descriptor }) => [descriptor, mask]) + ); + splitLabelmap( + labelmap, + targets.map(({ descriptor }) => descriptor), + (descriptor, extent) => { + const mask = maskByDescriptor.get(descriptor)!; + const binding = createBindingForImage( + parentImageId, + extent, + item.source, + item.name + ); + attachMaskBinding(mask.id, binding); + return { + labelValue: SEGMENT_VALUE, + mask: maskScalars(binding.image), + }; + } + ); + restored = targets.map(({ mask }) => mask); + } + if (restored.length) restoredImportIds.add(item.id); + else + skipped.push({ name: item.name, reason: 'labelmap holds no segments' }); + }); + + return { restoredImportIds, maskIdMap, skipped }; + } + + return { serialize, deserialize }; +} diff --git a/src/segmentation/masks/overlap.ts b/src/segmentation/masks/overlap.ts index 4a87514c5..159dbada7 100644 --- a/src/segmentation/masks/overlap.ts +++ b/src/segmentation/masks/overlap.ts @@ -5,8 +5,10 @@ import { } from '@/src/segmentation/model'; import { clipExtent, + emptyExtent, extentContainsIndex, extentSize, + extentUnion, isEmptyExtent, maskOffset, type Extent3D, @@ -152,29 +154,120 @@ export function masksIntersect(a: BoundedScalars, b: BoundedScalars) { return false; } +/** + * A layer's claimed voxels, one bit each, over a box that takes in every mask + * being grouped. Asking whether one more mask fits is then a single sweep of + * that mask's own extent, rather than a sweep of a shared box per mask already + * in the layer. + */ +type Occupancy = MaskBounds & { bits: Uint8Array }; + +function newOccupancy(extent: Extent3D): Occupancy { + const [mi, mj, mk] = extentSize(extent); + return { + extent, + mi, + mj, + bits: new Uint8Array(Math.ceil((mi * mj * mk) / 8)), + }; +} + +/** + * Walks `bounded`'s extent a row at a time, handing each row the offset it + * starts at in the mask, the offset the same voxel sits at in `into`, and how + * many voxels the row holds. Stops at the first row answering true. `into` + * must take in the mask's extent. + */ +function maskRows( + bounded: BoundedScalars, + into: MaskBounds, + row: (from: number, to: number, count: number) => boolean +) { + const { extent } = bounded; + const [ni, nj, nk] = extentSize(extent); + for (let index = 0; index < nj * nk; index += 1) { + const j = extent[2] + (index % nj); + const k = extent[4] + Math.floor(index / nj); + const from = maskOffset(bounded, extent[0], j, k); + const to = maskOffset(into, extent[0], j, k); + if (row(from, to, ni)) return true; + } + return false; +} + +const occupancyHits = (occupied: Occupancy, bounded: BoundedScalars) => + maskRows(bounded, occupied, (from, to, count) => { + for (let n = 0; n < count; n += 1) { + const at = to + n; + if (bounded.scalars[from + n] && occupied.bits[at >> 3] & (1 << (at & 7))) + return true; + } + return false; + }); + +function occupy(occupied: Occupancy, bounded: BoundedScalars) { + maskRows(bounded, occupied, (from, to, count) => { + for (let n = 0; n < count; n += 1) { + const at = to + n; + if (bounded.scalars[from + n]) occupied.bits[at >> 3] |= 1 << (at & 7); + } + return false; + }); +} + +function maskedBounds(masks: Array) { + let bounds: Extent3D | undefined; + masks.forEach((bounded) => { + if (!bounded) return; + bounds = bounds ? extentUnion(bounds, bounded.extent) : bounded.extent; + }); + return bounds; +} + /** * Items grouped so no group holds two masks that claim a voxel in common. * Greedy first fit: an item takes the lowest group it does not intersect, so a * segmentation with no overlap stays one group, in order. An item with no mask * claims nothing and joins the first group. + * + * A layer answers from its occupancy once it holds more than one mask, which + * is what keeps the cost with the masks' extent instead of with mask pairs. A + * layer holding one mask is asked directly, so layers that never take a second + * mask - every mask overlapping every other - allocate nothing. */ export function groupByLayer( items: T[], maskOf: (item: T) => BoundedScalars | undefined ) { - const layers: Array<{ items: T[]; masks: BoundedScalars[] }> = []; - const fits = ( - layer: { masks: BoundedScalars[] }, - mask: BoundedScalars | undefined - ) => !mask || layer.masks.every((other) => !masksIntersect(mask, other)); - - items.forEach((item) => { - const mask = maskOf(item); + type Layer = { items: T[]; masks: BoundedScalars[]; occupied?: Occupancy }; + const masks = items.map(maskOf); + const bounds = maskedBounds(masks) ?? emptyExtent(); + const layers: Layer[] = []; + + const fits = (layer: Layer, mask: BoundedScalars | undefined) => { + if (!mask) return true; + if (layer.occupied) return !occupancyHits(layer.occupied, mask); + return layer.masks.every((other) => !masksIntersect(mask, other)); + }; + + const accept = (layer: Layer, mask: BoundedScalars) => { + layer.masks.push(mask); + if (layer.occupied) { + occupy(layer.occupied, mask); + return; + } + if (layer.masks.length < 2) return; + const occupied = newOccupancy(bounds); + layer.masks.forEach((held) => occupy(occupied, held)); + layer.occupied = occupied; + }; + + masks.forEach((mask, index) => { const found = layers.find((layer) => fits(layer, mask)); const layer = found ?? { items: [], masks: [] }; if (!found) layers.push(layer); - layer.items.push(item); - if (mask) layer.masks.push(mask); + layer.items.push(items[index]); + if (mask) accept(layer, mask); }); return layers.map((layer) => layer.items); diff --git a/src/segmentation/model.ts b/src/segmentation/model.ts index 76f62d905..f5d8e7380 100644 --- a/src/segmentation/model.ts +++ b/src/segmentation/model.ts @@ -1,4 +1,4 @@ -import type { Extent3D } from '@/src/segmentation/geometry'; +import { isEmptyExtent, type Extent3D } from '@/src/segmentation/geometry'; import type { ProcessingResultSource } from '@/src/types'; import type { RGBAColor, TypedArray } from '@kitware/vtk.js/types'; @@ -33,6 +33,17 @@ export type SegmentMask = { }; }; +/** + * Whether a mask holds anything. A record alone is not content, and neither is + * storage bound over an empty extent: both mean the segment was resolved on + * this image but never painted. Readers that count or gate on what an image + * actually has ask this instead of whether the record exists. + */ +export const maskHasContent = (mask: SegmentMask) => { + const binding = mask.representations.labelmap; + return !!binding && !isEmptyExtent(binding.extent); +}; + export const LABELMAP_BACKGROUND_VALUE = 0; export const makeDefaultSegmentName = (value: number) => `Segment ${value}`; diff --git a/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue b/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue new file mode 100644 index 000000000..2bc7b9fa0 --- /dev/null +++ b/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue @@ -0,0 +1,263 @@ + + + diff --git a/src/segmentation/segmentRegistry.ts b/src/segmentation/segmentRegistry.ts index 14ef5b2c6..750d4cdbe 100644 --- a/src/segmentation/segmentRegistry.ts +++ b/src/segmentation/segmentRegistry.ts @@ -63,6 +63,38 @@ export const createSegmentRegistry = ({ segmentOrder.value.map((id) => segmentById.value[id]) ); + /** + * Trimmed name to the ids carrying it. Maintained as segments arrive, are + * renamed and leave, so a name lookup and the uniqueness scans cost a probe + * instead of a walk over every segment: an import mints one segment per + * label, and a thousand-label labelmap is in scope. + */ + const idsByName = new Map(); + + const indexName = (name: string, id: string) => { + const key = name.trim(); + const ids = idsByName.get(key); + if (ids) ids.push(id); + else idsByName.set(key, [id]); + }; + + // Where the last search under a prefix ended. Every suffix below it is + // taken until a name leaves, so a run of mints sharing a stem resumes there + // and still lands on the lowest free suffix. + const suffixFloors = new Map(); + + const unindexName = (name: string, id: string) => { + const key = name.trim(); + const ids = idsByName.get(key); + if (!ids) return; + const at = ids.indexOf(id); + if (at !== -1) ids.splice(at, 1); + if (ids.length === 0) idsByName.delete(key); + suffixFloors.clear(); + }; + + const nameTaken = (name: string) => idsByName.has(name); + const selectedSegmentId = ref>(); const selectionRevision = ref(0); @@ -95,24 +127,34 @@ export const createSegmentRegistry = ({ (id === undefined || id === null ? undefined : orderIndex.value.get(id)) ?? -1; - const findSegmentByName = (name: Maybe) => - segmentList.value.find((type) => type.name === name); + const findSegmentByName = (name: Maybe) => { + if (name === undefined || name === null) return undefined; + const candidates = idsByName.get(name.trim()) ?? []; + // The index keys on the trimmed name; the answer is still an exact match. + const matches = candidates.filter( + (id) => segmentById.value[id]?.name === name + ); + if (matches.length <= 1) return getSegment(matches[0]); + // Several segments carry the name: the first in registry order answers. + return segmentList.value.find((type) => matches.includes(type.id)); + }; - const uniqueName = (stem: string) => { - const taken = new Set(segmentList.value.map((type) => type.name.trim())); - if (!taken.has(stem)) return stem; - let index = 2; - while (taken.has(`${stem} (${index})`)) index += 1; - return `${stem} (${index})`; + const lowestFreeName = (prefix: string, tail: string, first: number) => { + let index = suffixFloors.get(prefix) ?? first; + while (nameTaken(`${prefix}${index}${tail}`)) index += 1; + suffixFloors.set(prefix, index); + return `${prefix}${index}${tail}`; }; - const defaultName = () => { - const taken = new Set(segmentList.value.map((type) => type.name.trim())); - let index = 1; - while (taken.has(`Segment ${index}`)) index += 1; - return `Segment ${index}`; + // The name index and every lookup ignore surrounding space, so the stem has + // to be trimmed as well. + const uniqueName = (stem: string) => { + const base = stem.trim(); + return nameTaken(base) ? lowestFreeName(`${base} (`, ')', 2) : base; }; + const defaultName = () => lowestFreeName('Segment ', '', 1); + let nextColorIndex = 0; const nextColor = () => { const color = cssColorToRGBA(TOOL_COLORS[nextColorIndex]); @@ -123,18 +165,21 @@ export const createSegmentRegistry = ({ /** Mints a segment without touching the selection. Allocates no voxels. */ const mintSegment = (init: SegmentInit = {}) => { const id = useIdStore().nextId(); - segmentById.value = { - ...segmentById.value, - [id]: { - name: defaultName(), - color: nextColor(), - visible: true, - locked: false, - ...cleanUndefined(init), - id, - }, + const stated = cleanUndefined(init); + // Mutated in place, and the default name is searched for only when the + // caller states none: copying the record and the order per mint made an + // import quadratic in its label count. + const segment = { + name: stated.name ?? defaultName(), + color: nextColor(), + visible: true, + locked: false, + ...stated, + id, }; - segmentOrder.value = [...segmentOrder.value, id]; + segmentById.value[id] = segment; + segmentOrder.value.push(id); + indexName(segment.name, id); return id; }; @@ -147,18 +192,22 @@ export const createSegmentRegistry = ({ const updateSegment = (id: string, patch: SegmentInit) => { const type = segmentById.value[id]; if (!type) return; - segmentById.value = { - ...segmentById.value, - [id]: { ...type, ...patch, id }, - }; + const next = { ...type, ...patch, id }; + if (next.name !== type.name) { + unindexName(type.name, id); + indexName(next.name, id); + } + segmentById.value[id] = next; }; // Deleting a referenced segment takes its masks and shapes with it; the // caller owns the confirmation. const deleteSegment = (id: string) => { - if (!segmentById.value[id]) return; + const type = segmentById.value[id]; + if (!type) return; removeReferences(id); segmentOrder.value = segmentOrder.value.filter((key) => key !== id); + unindexName(type.name, id); segmentById.value = omit(segmentById.value, id); if (selectedSegmentId.value === id) { selectSegment(segmentOrder.value[0]); @@ -172,6 +221,14 @@ export const createSegmentRegistry = ({ segmentOrder.value = order; }; + /** + * Where `ensureSelectedSegment` would land, selecting and minting nothing. + * An empty registry has no answer, and what would be minted there is a fresh + * segment carrying the defaults. + */ + const presumedSegmentId = () => + selectedSegment.value?.id ?? segmentList.value[0]?.id; + const ensureSelectedSegment = () => { if (selectedSegment.value) return selectedSegment.value.id; const first = segmentList.value[0]; @@ -240,13 +297,21 @@ export const createSegmentRegistry = ({ * and every incoming reference is remapped through the returned map, so an * import into a populated scene overwrites nothing. */ - const adopt = (incoming: Maybe) => - Object.fromEntries( - (incoming ?? []).map(({ id, ...init }) => [ - id, - mintSegment(init as SegmentInit), // side effect in Array.map - ]) - ); + const adopt = (incoming: Maybe) => { + // Prototype-free: a file's ids are its own, so an id spelling an + // Object.prototype key ('constructor', 'toString') must not read as + // already seen here, nor hand a caller an inherited member in place of a + // miss when it looks the id up in the returned map. + const idMap: Record = Object.create(null); + (incoming ?? []).forEach(({ id, ...init }) => { + // Nothing makes a file's ids unique, and only one segment can answer for + // an id. The first entry wins; minting the rest as well would leave + // segments in the sidebar that no mask or shape can ever reference. + if (Object.hasOwn(idMap, id)) return; + idMap[id] = mintSegment(init as SegmentInit); + }); + return idMap; + }; return { segmentById, @@ -265,6 +330,7 @@ export const createSegmentRegistry = ({ updateSegment, moveSegment, deleteSegment, + presumedSegmentId, ensureSelectedSegment, segmentNamed, replaceConfigSegments, diff --git a/src/segmentation/segments.ts b/src/segmentation/segments.ts new file mode 100644 index 000000000..4bc24427a --- /dev/null +++ b/src/segmentation/segments.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia'; +import { markRaw } from 'vue'; + +import type { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { createSegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { + removeSegmentReferences, + segmentIsReferenced, +} from '@/src/segmentation/segmentReferences'; + +/** + * Paint, rectangles, polygons and rulers share this registry and selection. + * The same segment can hold masks and shapes on every image. + */ +export const useSegmentStore = defineStore('segments', () => { + const registry = createSegmentRegistry({ + hasReferences: segmentIsReferenced, + removeReferences: removeSegmentReferences, + }); + + function serialize(state: StateFile) { + state.manifest.segments = registry.serialize(); + const selected = registry.selectedSegmentId.value; + if (selected) state.manifest.selectedSegment = selected; + } + + /** Fresh ids for the incoming segments; the map remaps every reference. */ + function deserialize(manifest: Manifest) { + const segmentIdMap = registry.adopt(manifest.segments); + const selected = + manifest.selectedSegment && segmentIdMap[manifest.selectedSegment]; + // An import into a populated scene leaves the user's selection alone. + if (selected && !registry.selectedSegment.value) + registry.selectSegment(selected); + return segmentIdMap; + } + + // Raw: a pinia store is reactive, and a proxy of the registry would unwrap + // its refs out from under every consumer that holds them. + return { + segments: markRaw(registry), + serialize, + deserialize, + }; +}); diff --git a/src/segmentation/store.ts b/src/segmentation/store.ts new file mode 100644 index 000000000..fbdc6ef40 --- /dev/null +++ b/src/segmentation/store.ts @@ -0,0 +1,677 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import { defineStore } from 'pinia'; +import { markRaw, reactive, ref } from 'vue'; +import type { RGBAColor } from '@kitware/vtk.js/types'; + +import { CATEGORICAL_COLORS } from '@/src/config'; +import { NO_NAME } from '@/src/constants'; +import { createMaskFileNamer } from '@/src/segmentation/io/maskFileNaming'; +import { + LABELMAP_MAX_VALUE, + SEGMENT_VALUE, +} from '@/src/segmentation/masks/labelValue'; +import { allocateMask } from '@/src/segmentation/masks/storage'; +import { createSegmentProjection } from '@/src/segmentation/rendering/projection'; +import { createVoxelAccess } from '@/src/segmentation/masks/voxelAccess'; +import { + createSegmentationWire, + type LabelmapIO, +} from '@/src/segmentation/io/stateFile'; + +export type { LabelmapIO }; +export { LABELMAP_MAX_VALUE }; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { declareManifestRefs } from '@/src/core/manifestRefs'; +import { + decodeLabelmapSegments, + importLabelmapImage, + splitLabelmap, +} from '@/src/segmentation/io/import'; +import { useIdStore } from '@/src/store/id'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import type { Maybe, ProcessingResultSource } from '@/src/types'; +import { + type DataSelection, + getSelectionStem, +} from '@/src/utils/dataSelection'; +import { + DEFAULT_SEGMENTATION_FILL_OPACITY, + listMasks, + maskScalars, + type LabelmapBinding, + type LabelmapSegment, + type SegmentMask, + type Segmentation, + type SegmentationDisplayPatch, +} from '@/src/segmentation/model'; +import { + emptyExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { declareSegmentReferences } from '@/src/segmentation/segmentReferences'; +import { useMessageStore } from '@/src/store/messages'; +import { + cleanUndefined, + ensureError, + isRecord, + removeFromArray, +} from '@/src/utils'; +import { cycleColors } from '@/src/utils/color'; +import vtkLabelMap from '@/src/vtk/LabelMap'; + +export type { ImportedSegment } from '@/src/segmentation/io/import'; + +// The manifest references this store's remove cascade keeps clean (see the +// onImageDeleted registration below), declared for the dev-only save backstop. +declareManifestRefs('segmentations', (manifest) => { + const segmentations = Array.isArray(manifest.segmentations) + ? manifest.segmentations + : []; + return segmentations.flatMap((raw, index) => { + if (!isRecord(raw)) return []; + const where = `segmentations[${index}]`; + const masks = Array.isArray(raw.masks) ? raw.masks : []; + return [ + ...(typeof raw.parentImage === 'string' + ? [ + { + kind: 'dataset' as const, + id: raw.parentImage, + where: `${where}.parentImage`, + }, + ] + : []), + ...masks.flatMap((mask, maskIndex) => + isRecord(mask) && typeof mask.segmentId === 'string' + ? [ + { + kind: 'segment' as const, + id: mask.segmentId, + where: `${where}.masks[${maskIndex}].segmentId`, + }, + ] + : [] + ), + ]; + }); +}); + +export const useSegmentationStore = defineStore('segmentation', () => { + const edits = useSegmentationEditsStore(); + const imageCacheStore = useImageCacheStore(); + const segmentRegistry = useSegmentStore().segments; + + const segmentations = reactive>({}); + const convertingLabelmaps = reactive(new Set()); + // The conversion running for a child image ONTO ONE PARENT, so a second + // caller for that same pair joins it instead of splitting the same labelmap + // twice. The parent belongs in the key: the same child going onto another + // parent is other work, and joining it would hand that caller masks made on + // an image it never named. + const conversions = new Map>(); + /** + * How many bound masks hold each name, so picking a default name probes this + * rather than walking every mask in the scene. A restore attaches the names + * the file states, which may repeat, so it counts holders instead of only + * remembering the name: releasing one mask must not free a name another + * still holds. + */ + const maskNameHolders = new Map(); + const holdMaskName = (name: string) => + maskNameHolders.set(name, (maskNameHolders.get(name) ?? 0) + 1); + const releaseMaskName = (name: string) => { + const holders = maskNameHolders.get(name) ?? 0; + if (holders > 1) maskNameHolders.set(name, holders - 1); + else maskNameHolders.delete(name); + }; + const maskFileNamer = createMaskFileNamer(() => maskNameHolders); + + /** + * Each segmentation's mask id per segment. One image holds at most one mask + * per segment, so the lookup every create, edit and panel row does is a probe + * instead of a walk over `order`. Reactive: components resolve their mask + * inside computeds, so a mask appearing has to reach them. + */ + const maskIdsBySegment = reactive(new Map>()); + + function getSegmentation(segmentationId: string) { + const segmentation = segmentations[segmentationId]; + if (!segmentation) throw new Error('No such segmentation'); + return segmentation; + } + + // SegmentMask ids are globally unique and one segmentation per image is + // enforced, so a segment addresses itself; the segmentation is looked up. + const segmentationOfMask = (maskId: string) => + Object.values(segmentations).find( + (segmentation) => maskId in segmentation.masks + ); + + const findMask = (maskId: string) => + segmentationOfMask(maskId)?.masks[maskId]; + + function getSegmentationOfMask(maskId: string) { + const segmentation = segmentationOfMask(maskId); + if (!segmentation) throw new Error('No such segment'); + return segmentation; + } + + function getMask(maskId: string) { + const segment = findMask(maskId); + if (!segment) throw new Error('No such segment'); + return segment; + } + + const getSegmentationForImage = (parentImageId: string) => + Object.values(segmentations).find( + (segmentation) => segmentation.parentImageId === parentImageId + ); + + function ensureSegmentationForImage(parentImageId: string) { + const existing = getSegmentationForImage(parentImageId); + if (existing) return existing; + + const id = useIdStore().nextId(); + segmentations[id] = { + id, + name: imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME, + parentImageId, + masks: {}, + order: [], + fillOpacity: DEFAULT_SEGMENTATION_FILL_OPACITY, + outlineOpacity: 1, + outlineThickness: 2, + }; + maskIdsBySegment.set(id, new Map()); + return segmentations[id]; + } + + /** Creates one mask per (image, segment), refusing duplicate or dangling identity. */ + function createMask(segmentationId: string, segmentId: string) { + const segmentation = getSegmentation(segmentationId); + if (!segmentRegistry.getSegment(segmentId)) + throw new Error('No such segment type'); + if (maskFor(segmentation.parentImageId, segmentId)) + throw new Error('Segment already has a mask on this image'); + const id = useIdStore().nextId(); + segmentation.masks[id] = { id, segmentId, representations: {} }; + segmentation.order.push(id); + maskIdsBySegment.get(segmentation.id)?.set(segmentId, id); + return segmentation.masks[id]; + } + + /** + * A fresh binding over voxels on the parent's grid, covering `extent`. `name` + * is the name a manifest carried: it reaches the saved zip's entry path, so a + * restore that generated one instead would rename the file on every round + * trip. Duplicates are fine, serialize resolves the archive path against the + * ones it has already used. + */ + function createBindingForImage( + parentImageId: string, + extent: Extent3D = emptyExtent(), + source?: ProcessingResultSource, + name?: string + ): LabelmapBinding { + const imageData = imageCacheStore.getVtkImageData(parentImageId); + if (!imageData) throw new Error('No such parent image'); + + const baseName = + imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME; + return { + image: markRaw(allocateMask(imageData, extent)), + extent, + name: name ?? maskFileNamer.pick(parentImageId, baseName), + ...(source ? { source } : {}), + }; + } + + /** Attaches prepared storage to a mask without exposing its mutable record to importers. */ + function attachMaskBinding(maskId: string, binding: LabelmapBinding) { + const mask = getMask(maskId); + if (mask.representations.labelmap) + throw new Error('Mask already has storage'); + mask.representations.labelmap = { + ...binding, + image: markRaw(binding.image), + extent: [...binding.extent], + }; + holdMaskName(mask.representations.labelmap.name); + return mask.representations.labelmap; + } + + function detachMask(segmentation: Segmentation, maskId: string) { + edits.beforeEdit(); + const mask = segmentation.masks[maskId]; + const { segmentId } = mask ?? {}; + const boundName = mask?.representations.labelmap?.name; + removeFromArray(segmentation.order, maskId); + delete segmentation.masks[maskId]; + const index = maskIdsBySegment.get(segmentation.id); + if (segmentId && index?.get(segmentId) === maskId) index.delete(segmentId); + if (boundName !== undefined) releaseMaskName(boundName); + } + + const maskLocked = (mask: SegmentMask) => + segmentRegistry.appearanceOf(mask.segmentId).locked; + + const isLocked = (maskId: string) => + segmentRegistry.appearanceOf(findMask(maskId)?.segmentId).locked; + + /** + * The segment a file's descriptor binds to: the one already carrying that + * exact name, or a new one minted from the file. The registry's own color wins + * on a match. A name already taken on this image mints a suffixed segment + * instead, since one image holds at most one mask per segment. + */ + function bindDescriptorSegment( + parentImageId: string, + descriptor: LabelmapSegment, + ownSegment = false + ) { + const usable = (segmentId: Maybe) => + !!segmentId && + !!segmentRegistry.getSegment(segmentId) && + !maskFor(parentImageId, segmentId); + const existing = ownSegment + ? undefined + : segmentRegistry.findSegmentByName(descriptor.name); + if (existing && usable(existing.id)) return existing.id; + // A minted segment takes the file's whole description; a matched one keeps + // what the registry already says, its visibility and lock included. + return segmentRegistry.mintSegment({ + name: segmentRegistry.uniqueName(descriptor.name), + color: [...descriptor.color] as RGBAColor, + visible: descriptor.visible, + locked: descriptor.locked ?? false, + ...cleanUndefined({ + fillOpacity: descriptor.fillOpacity, + outlineOpacity: descriptor.outlineOpacity, + }), + }); + } + + /** + * Mints one mask per descriptor and fills it. The masks + * share one segmentation, so label values are assigned against what is + * already in it and a taken value gets remapped. + */ + function splitLabelmapIntoMasks( + parentImageId: string, + labelmap: vtkLabelMap, + descriptors: LabelmapSegment[], + options: { + source?: ProcessingResultSource; + name?: string; + // A descriptor carrying display of its own mints its segment rather than + // joining one of the same name, whose display it would otherwise lose. + ownSegments?: boolean; + } = {} + ) { + // Identity is committed before storage: the segmentation, the registry + // segment and the mask record all precede the binding that would be the + // first to notice the parent has gone. Refuse up front, so a conversion + // whose parent was removed while it ran mints nothing at all. + if (!imageCacheStore.getVtkImageData(parentImageId)) + throw new Error('No such parent image'); + edits.beforeEdit(); + const segmentation = ensureSegmentationForImage(parentImageId); + const created: SegmentMask[] = []; + + splitLabelmap(labelmap, descriptors, (descriptor, extent) => { + const segment = createMask( + segmentation.id, + bindDescriptorSegment(parentImageId, descriptor, options.ownSegments) + ); + + const binding = createBindingForImage( + parentImageId, + extent, + options.source, + options.name + ); + attachMaskBinding(segment.id, binding); + created.push(segment); + + // The copy rewrites the source's value, so the mask holds SEGMENT_VALUE + // whatever the file it came from called this segment. + return { labelValue: SEGMENT_VALUE, mask: maskScalars(binding.image) }; + }); + + return created; + } + + // Deliberately separate from createMask's cursor: a descriptor-less + // labelmap must decode to the same catalog whether it came from a cold + // restore or a live conversion, regardless of how many segments this + // session has otherwise created. + const getNextDecodeColor = cycleColors(CATEGORICAL_COLORS); + + function decodeSegments( + imageId: DataSelection | undefined, + image: vtkLabelMap, + options: { component?: number; headerMetadata?: Map } = {} + ) { + return decodeLabelmapSegments(imageId, image, { + ...options, + // A descriptor-less labelmap reads as the file it arrived in, not as + // 'Segment N'; the cold restore decodes through here too, so the two + // paths keep naming one labelmap alike. + baseName: imageId === undefined ? undefined : getSelectionStem(imageId), + nextColor: getNextDecodeColor, + }); + } + + async function convertImageToLabelmap( + imageID: DataSelection, + parentID: DataSelection, + source?: ProcessingResultSource, + descriptions: Array< + Pick & Partial> + > = [] + ) { + // A second conversion of an image already converting onto the same parent + // would split it again and mint a suffixed duplicate of every segment, and + // the first call's cleanup would clear the pending flag while the second + // still ran. Both callers share the one conversion and see it end when it + // really ends. + const pair = `${imageID}|${parentID}`; + const running = conversions.get(pair); + if (running) return running; + + const bySourceValue = new Map( + descriptions.map((descriptor) => [ + descriptor.value, + cleanUndefined(descriptor), + ]) + ); + convertingLabelmaps.add(imageID); + const conversion = importLabelmapImage(imageID, parentID, { + decode: (labelmap, component) => + decodeSegments(imageID, labelmap, { component }) as Promise< + LabelmapSegment[] + >, + split: (labelmap, descriptors) => { + const created = splitLabelmapIntoMasks( + parentID, + labelmap, + // Identity is chosen by name, so explicit descriptions must precede + // binding to a type shared by other images. + descriptors.map((descriptor) => ({ + ...descriptor, + ...bySourceValue.get(descriptor.value), + })), + { source } + ); + if (created.length && !segmentRegistry.selectedSegment.value) { + segmentRegistry.selectSegment(created[0].segmentId); + } + return created.map((segment) => segment.id); + }, + }); + conversions.set(pair, conversion); + try { + return await conversion; + } finally { + conversions.delete(pair); + convertingLabelmaps.delete(imageID); + } + } + + /** + * Starts a conversion nobody awaits, and reports its failure. A conversion + * outlives the load or the click that started it -- the parent image can be + * removed while the resample runs -- so the rejection needs somewhere to + * land instead of going unhandled. + */ + function startLabelmapConversion( + imageID: DataSelection, + parentID: DataSelection + ) { + return convertImageToLabelmap(imageID, parentID).catch((error) => { + useMessageStore().addError('Failed to convert image to a labelmap', { + error: ensureError(error), + }); + }); + } + + const saveFormat = ref('vti'); + + /** The single voxel-allocation point: no other operation creates storage. */ + function ensureLabelmapBinding(maskId: string) { + const segmentation = getSegmentationOfMask(maskId); + const segment = segmentation.masks[maskId]; + if (segment.representations.labelmap) + return segment.representations.labelmap; + + return attachMaskBinding( + maskId, + createBindingForImage(segmentation.parentImageId) + ); + } + + const findMaskBinding = (maskId: string) => + findMask(maskId)?.representations.labelmap; + + const { maskVoxels, findMaskVoxels, voxelClaim } = createVoxelAccess({ + imageCacheStore, + findMask, + getMask, + segmentationOfMask, + ensureLabelmapBinding, + maskLocked, + }); + + /** The image's segments in `order`, or none when it has no segmentation. */ + function imageMasks(parentImageId: string) { + const segmentation = getSegmentationForImage(parentImageId); + return segmentation ? listMasks(segmentation) : []; + } + + /** + * The segments of an image a process may edit: unlocked, since a locked one + * is not editable, and holding voxels, since an empty mask has no content to + * process. + */ + function editableMasks(parentImageId: string) { + return imageMasks(parentImageId).flatMap((segment) => { + const binding = segment.representations.labelmap; + if (maskLocked(segment) || !binding || isEmptyExtent(binding.extent)) + return []; + return [{ maskId: segment.id, labelValue: SEGMENT_VALUE }]; + }); + } + + /** + * Back-to-front depth for each mask. Earlier registry entries get greater + * offsets toward the viewer, consistently across images. + */ + function maskLayersForImage(parentImageId: string) { + return imageMasks(parentImageId).flatMap((segment) => { + return segment.representations.labelmap + ? [ + { + maskId: segment.id, + stackIndex: + segmentRegistry.segmentList.value.length - + 1 - + segmentRegistry.orderIndexOf(segment.segmentId), + }, + ] + : []; + }); + } + + const updateSegmentationDisplay = ( + segmentationId: string, + patch: SegmentationDisplayPatch + ) => Object.assign(getSegmentation(segmentationId), patch); + + /** The mask holds this segment and nothing else, so its voxels go with it. */ + function deleteMask(maskId: string) { + detachMask(getSegmentationOfMask(maskId), maskId); + } + + function removeSegmentation(segmentationId: string) { + edits.beforeEdit(); + const segmentation = segmentations[segmentationId]; + if (segmentation) + listMasks(segmentation).forEach((mask) => { + const binding = mask.representations.labelmap; + if (binding) releaseMaskName(binding.name); + }); + delete segmentations[segmentationId]; + maskIdsBySegment.delete(segmentationId); + } + + // --- edit targets --- // + + const maskFor = (imageId: Maybe, segmentId: Maybe) => { + if (!imageId || !segmentId) return undefined; + const segmentation = getSegmentationForImage(imageId); + if (!segmentation) return undefined; + const maskId = maskIdsBySegment.get(segmentation.id)?.get(segmentId); + return maskId ? segmentation.masks[maskId] : undefined; + }; + + /** The mask for (image, segment). Creates identity only, never voxels. */ + function ensureMask(imageId: string, segmentId: string) { + const existing = maskFor(imageId, segmentId); + if (existing) return existing; + const segmentation = ensureSegmentationForImage(imageId); + return createMask(segmentation.id, segmentId); + } + + /** Whether a segment id is live anywhere, used to tell stale ids from foreign ones. */ + const maskExists = (maskId: string) => !!findMask(maskId); + + // A segment the caller named that no longer exists is a stale reference, not + // a target: the edit falls through to the selected one. + const liveSegmentId = (segmentId: Maybe) => + segmentId && segmentRegistry.getSegment(segmentId) ? segmentId : undefined; + + /** + * The mask an edit would land in, if it already exists. Creates nothing, so + * an operation with nothing to allocate for, erasing above all, can refuse + * before a mask is created. + */ + function findEditTarget(imageId: string, preferredSegmentId?: Maybe) { + const segmentId = + liveSegmentId(preferredSegmentId) ?? + segmentRegistry.selectedSegmentId.value; + return maskFor(imageId, segmentId)?.id; + } + + /** + * Whether the segment an edit would land in is locked. The refusal cannot + * wait for a resolved mask: `resolveEditTarget` returns a mask id, so it has + * to mint the record and its segmentation before anything can be asked about + * the lock, and a refused edit would leave both behind. This answers from the + * segment alone, creating nothing, so every edit path can refuse first. + */ + const editTargetLocked = (preferredSegmentId?: Maybe) => + segmentRegistry.appearanceOf( + liveSegmentId(preferredSegmentId) ?? segmentRegistry.presumedSegmentId() + ).locked; + + /** + * Resolves or creates the mask an edit targets. With nothing selected the + * first edit mints and selects a segment, then takes this image's mask of it. + * Callers refusing a locked segment ask `editTargetLocked` before this. + */ + function resolveEditTarget( + imageId: string, + preferredSegmentId?: Maybe + ) { + edits.beforeEdit(); + const segmentId = + liveSegmentId(preferredSegmentId) ?? + segmentRegistry.ensureSelectedSegment(); + return ensureMask(imageId, segmentId).id; + } + + const masksOfSegment = (segmentId: string) => + Object.values(segmentations).flatMap((segmentation) => + listMasks(segmentation).filter( + (segment) => segment.segmentId === segmentId + ) + ); + + declareSegmentReferences('labelmaps', { + has: (segmentId) => masksOfSegment(segmentId).length > 0, + remove: (segmentId) => + masksOfSegment(segmentId).forEach((segment) => deleteMask(segment.id)), + }); + + // --- render sync --- // + + const labelmapDescriptorByMask = createSegmentProjection({ + segmentations, + segmentRegistry, + }); + + // --- state file --- // + + const { serialize, deserialize } = createSegmentationWire({ + segmentations, + saveFormat, + imageCacheStore, + segmentRegistry, + labelmapDescriptorByMask, + createMask, + createBindingForImage, + attachMaskBinding, + decodeSegments, + ensureSegmentationForImage, + getSegmentationForImage, + maskFor, + splitLabelmapIntoMasks, + }); + + // --- handle deletions --- // + + onImageDeleted((deleted) => { + deleted.forEach((parentImageId) => { + maskFileNamer.forget(parentImageId); + const id = getSegmentationForImage(parentImageId)?.id; + if (id) removeSegmentation(id); + }); + }); + + return { + segmentations, + convertingLabelmaps, + labelmapDescriptorByMask, + maskFor, + findEditTarget, + resolveEditTarget, + editTargetLocked, + maskExists, + getSegmentationForImage, + ensureSegmentationForImage, + segmentationOfMask, + getMask, + findMaskBinding, + maskVoxels, + findMaskVoxels, + createMask, + ensureLabelmapBinding, + isLocked, + updateSegmentationDisplay, + deleteMask, + removeSegmentation, + splitLabelmapIntoMasks, + decodeSegments, + convertImageToLabelmap, + startLabelmapConversion, + saveFormat, + voxelClaim, + imageMasks, + editableMasks, + maskLayersForImage, + serialize, + deserialize, + }; +}); diff --git a/src/shims-vtk.d.ts b/src/shims-vtk.d.ts index ec7d1a69b..a5caa9159 100644 --- a/src/shims-vtk.d.ts +++ b/src/shims-vtk.d.ts @@ -79,6 +79,8 @@ declare module '@kitware/vtk.js/Widgets/Core/WidgetManager' { } export interface vtkWidgetManager extends vtkObject { + getCursorStyles(): Record; + setCursorStyles(styles: Record): boolean; setCaptureOn(cap: CaptureOn): boolean; getCaptureOn(): CaptureOn; setViewType(type: ViewTypes): boolean; diff --git a/src/store/__tests__/annotationToolImageDelete.spec.ts b/src/store/__tests__/annotationToolImageDelete.spec.ts index b7608a1e4..7f404768c 100644 --- a/src/store/__tests__/annotationToolImageDelete.spec.ts +++ b/src/store/__tests__/annotationToolImageDelete.spec.ts @@ -34,15 +34,7 @@ const makeRuler = ( imageID: string ): RequiredWithPartial< Ruler, - | 'id' - | 'color' - | 'strokeWidth' - | 'label' - | 'labelName' - | 'hidden' - | 'metadata' - | 'frame' - | 'source' + 'id' | 'segmentId' | 'hidden' | 'metadata' | 'frame' | 'source' > => ({ firstPoint: [1, 1, 1], secondPoint: [2, 2, 2], diff --git a/src/store/__tests__/datasetRemoveCascade.spec.ts b/src/store/__tests__/datasetRemoveCascade.spec.ts index 3ec1ef05a..591bf1184 100644 --- a/src/store/__tests__/datasetRemoveCascade.spec.ts +++ b/src/store/__tests__/datasetRemoveCascade.spec.ts @@ -1,16 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; +import { + boundMasks, + mintSegment, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; import { nextTick } from 'vue'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDatasetStore } from '@/src/store/datasets'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useRulerStore } from '@/src/store/tools/rulers'; import { useViewStore } from '@/src/store/views'; import { useCropStore } from '@/src/store/tools/crop'; -import { usePaintToolStore } from '@/src/store/tools/paint'; // Bind an existing (default-layout) view to a dataset via the public API — // `addView` is internal, but every fresh store already seats slot views. @@ -58,46 +62,81 @@ const makeRuler = (imageID: string) => placing: false, }) as never; +const seatMask = (imageId: string) => { + const segmentations = useSegmentationStore(); + const segmentationId = segmentations.ensureSegmentationForImage(imageId).id; + const maskId = segmentations.createMask(segmentationId, mintSegment()).id; + segmentations.maskVoxels(maskId).materialize(); + return { segmentationId, maskId }; +}; + describe('dataset remove — synchronous reference cascade', () => { beforeEach(() => { setActivePinia(createPinia()); }); - it('clears segment groups whose parent image was removed', () => { + it('clears segment masks whose parent image was removed', () => { seatImage('img-1', 'CT'); - const segmentGroups = useSegmentGroupStore(); - const groupId = segmentGroups.newLabelmapFromImage('img-1'); - expect(groupId).not.toBeNull(); - expect(segmentGroups.orderByParent['img-1']).toContain(groupId); + // The store subscribes to image deletion on setup, so seat it first. + useSegmentationStore(); + const { maskId } = seatMask('img-1'); + expect(boundMasks().map((mask) => mask.id)).toContain(maskId); useDatasetStore().remove('img-1'); - expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); - expect(segmentGroups.metadataByID).not.toHaveProperty(groupId as string); + expect(boundMasks()).toEqual([]); }); - it('clears ALL segment groups when an image has several (no splice-skip)', () => { + it('clears ALL segment masks when an image has several (no splice-skip)', () => { seatImage('img-1', 'CT'); - const segmentGroups = useSegmentGroupStore(); - const groupA = segmentGroups.newLabelmapFromImage('img-1'); - const groupB = segmentGroups.newLabelmapFromImage('img-1'); - const groupC = segmentGroups.newLabelmapFromImage('img-1'); - expect(groupA).not.toBeNull(); - expect(groupB).not.toBeNull(); - expect(groupC).not.toBeNull(); - expect(segmentGroups.orderByParent['img-1']).toEqual([ - groupA, - groupB, - groupC, - ]); + const segmentations = useSegmentationStore(); + const first = seatMask('img-1'); + const rest = ['A', 'B'].map((name) => { + const segment = segmentations.createMask( + first.segmentationId, + mintSegment({ + name, + }) + ); + segmentations.maskVoxels(segment.id).materialize(); + return segment.id; + }); + const maskIds = [first.maskId, ...rest]; + expect( + boundMasks() + .map((mask) => mask.id) + .sort() + ).toEqual([...maskIds].sort()); useDatasetStore().remove('img-1'); - expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); - [groupA, groupB, groupC].forEach((id) => { - expect(segmentGroups.metadataByID).not.toHaveProperty(id as string); - expect(segmentGroups.dataIndex).not.toHaveProperty(id as string); - }); + expect(boundMasks()).toEqual([]); + }); + + it('removes the segmentation and its masks with the parent image', () => { + seatImage('img-1', 'CT'); + const segmentations = useSegmentationStore(); + const { segmentationId, maskId } = seatMask('img-1'); + expect(boundMasks().map((mask) => mask.id)).toContain(maskId); + + useDatasetStore().remove('img-1'); + + expect(segmentations.getSegmentationForImage('img-1')).toBeFalsy(); + expect(segmentations.segmentations).not.toHaveProperty(segmentationId); + expect(boundMasks()).toEqual([]); + }); + + it('leaves another image segmentation intact', () => { + seatImage('img-1', 'CT'); + seatImage('img-2', 'PET'); + const segmentations = useSegmentationStore(); + seatMask('img-1'); + const kept = seatMask('img-2'); + + useDatasetStore().remove('img-1'); + + expect(segmentations.getSegmentationForImage('img-2')).toBeTruthy(); + expect(boundMasks().map((mask) => mask.id)).toEqual([kept.maskId]); }); it('clears annotation tools bound to the removed image', () => { @@ -155,17 +194,18 @@ describe('dataset remove — synchronous reference cascade', () => { expect('img-1' in cropStore.croppingByImageID).toBe(false); }); - it('nulls the active paint segment group when its parent image is removed', () => { + it('removes the records of a deleted image and keeps their type', () => { seatImage('img-1', 'CT'); - const segmentGroups = useSegmentGroupStore(); - const paintStore = usePaintToolStore(); - const groupId = segmentGroups.newLabelmapFromImage('img-1'); - paintStore.setActiveSegmentGroup(groupId); - expect(paintStore.activeSegmentGroupID).toBe(groupId); + const segmentationStore = useSegmentationStore(); + const { maskId } = seatMask('img-1'); + const { segmentId } = segmentationStore.getMask(maskId); + useSegmentStore().segments.selectSegment(segmentId); useDatasetStore().remove('img-1'); - expect(paintStore.activeSegmentGroupID).toBeNull(); + expect(segmentationStore.maskExists(maskId)).toBe(false); + // A type outlives the images it was painted on, so it stays selected. + expect(useSegmentStore().segments.selectedSegmentId.value).toBe(segmentId); }); it('leaves references to OTHER datasets intact', () => { @@ -204,8 +244,18 @@ describe('manifest-ref declarations (cascade-owned save backstop coverage)', () rectangles: { tools: [{ imageID: 'ghost-rect-img' }] }, polygons: { tools: [{ imageID: 'ghost-poly-img' }] }, crop: { 'ghost-crop-img': {} }, - paint: { activeSegmentGroupID: 'ghost-group' }, }, + segmentations: [ + { + parentImage: 'ghost-seg-img', + masks: [ + { + segmentId: 'ghost-segment', + representations: {}, + }, + ], + }, + ], }); const found = refs.map((ref) => `${ref.where} -> ${ref.kind} ${ref.id}`); @@ -225,7 +275,10 @@ describe('manifest-ref declarations (cascade-owned save backstop coverage)', () 'tools.crop[ghost-crop-img] -> dataset ghost-crop-img' ); expect(found).toContain( - 'tools.paint.activeSegmentGroupID -> segmentGroup ghost-group' + 'segmentations[0].parentImage -> dataset ghost-seg-img' + ); + expect(found).toContain( + 'segmentations[0].masks[0].segmentId -> segment ghost-segment' ); }); }); diff --git a/src/store/__tests__/fillHoles.spec.ts b/src/store/__tests__/fillHoles.spec.ts deleted file mode 100644 index 66ddb2195..000000000 --- a/src/store/__tests__/fillHoles.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setActivePinia, createPinia } from 'pinia'; -import { nextTick } from 'vue'; -import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; -import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { useFillHolesStore } from '@/src/store/tools/fillHoles'; -import { useImageCacheStore } from '@/src/store/image-cache'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import { useViewSliceStore } from '@/src/store/view-configs/slicing'; -import { useViewStore } from '@/src/store/views'; - -const fillHolesWorkerMock = vi.hoisted(() => vi.fn(async (input) => input)); - -// eslint-disable-next-line no-restricted-syntax -- the fill-holes worker has no counterpart in the node test environment -vi.mock('comlink', () => ({ - wrap: () => ({ - fillHolesWorker: fillHolesWorkerMock, - }), -})); - -function addScalars(image: vtkImageData, values: Uint8Array) { - image.getPointData().setScalars( - vtkDataArray.newInstance({ - numberOfComponents: 1, - values, - }) - ); -} - -type Vector3 = [number, number, number]; -type Matrix3 = [ - number, - number, - number, - number, - number, - number, - number, - number, - number, -]; - -function makeImage(dimensions: Vector3, spacing: Vector3, direction: Matrix3) { - const image = vtkImageData.newInstance({ spacing, direction }); - image.setDimensions(dimensions); - addScalars( - image, - new Uint8Array(dimensions[0] * dimensions[1] * dimensions[2]) - ); - image.computeTransforms(); - return image; -} - -function makeLabelMap( - dimensions: Vector3, - spacing: Vector3, - direction: Matrix3 -) { - const labelMap = vtkLabelMap.newInstance({ spacing, direction }); - labelMap.setDimensions(dimensions); - addScalars( - labelMap, - new Uint8Array(dimensions[0] * dimensions[1] * dimensions[2]) - ); - labelMap.computeTransforms(); - return labelMap; -} - -describe('Fill Holes store', () => { - beforeEach(() => { - setActivePinia(createPinia()); - fillHolesWorkerMock.mockClear(); - vi.stubGlobal( - 'Worker', - class { - terminate() {} - } - ); - }); - - async function setupFillHolesRun(labelMap: vtkLabelMap, parentSlice: number) { - const imageCacheStore = useImageCacheStore(); - const segmentGroupStore = useSegmentGroupStore(); - const viewStore = useViewStore(); - const viewSliceStore = useViewSliceStore(); - const paintStore = usePaintToolStore(); - const fillHolesStore = useFillHolesStore(); - - const parentImageID = 'parent-image'; - const parentImage = makeImage( - [10, 10, 10], - [1, 1, 1], - [1, 0, 0, 0, 1, 0, 0, 0, 1] - ); - imageCacheStore.addVTKImageData(parentImage, 'Parent', { - id: parentImageID, - }); - await nextTick(); - - const groupId = segmentGroupStore.addLabelmap(labelMap, { - name: 'Test group', - parentImage: parentImageID, - segments: { - order: [1], - byValue: { - 1: { - value: 1, - name: 'Segment 1', - color: [255, 0, 0, 255], - visible: true, - locked: false, - }, - }, - }, - }); - - const axialView = viewStore.visibleViews.find( - (view) => view.type === '2D' && view.options.orientation === 'Axial' - ); - expect(axialView).toBeDefined(); - viewStore.setDataForView(axialView!.id, parentImageID); - viewStore.setActiveView(axialView!.id); - viewSliceStore.updateConfig(axialView!.id, parentImageID, { - slice: parentSlice, - }); - - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - - return { fillHolesStore }; - } - - it('uses the label-map axis for the active parent view axis', async () => { - // Label-map I points along parent/world axial, so an active Axial view must - // be sent to the worker as axis 0 rather than the parent image's axis 2. - const labelMap = makeLabelMap( - [5, 10, 10], - [1, 1, 1], - [0, 0, 1, 0, 1, 0, 1, 0, 0] - ); - const { fillHolesStore } = await setupFillHolesRun(labelMap, 0); - - await fillHolesStore.computeAlgorithm(labelMap, 1); - - expect(fillHolesWorkerMock).toHaveBeenCalledTimes(1); - expect(fillHolesWorkerMock.mock.calls[0][0]).toMatchObject({ - axis: 0, - }); - }); - - it('converts the active parent slice into label-map slice space', async () => { - // Same orientation, different axial spacing: parent slice 4 is world z=4, - // which lands on label-map slice 2 when label-map z spacing is 2. - const labelMap = makeLabelMap( - [10, 10, 5], - [1, 1, 2], - [1, 0, 0, 0, 1, 0, 0, 0, 1] - ); - const { fillHolesStore } = await setupFillHolesRun(labelMap, 4); - - await fillHolesStore.computeAlgorithm(labelMap, 1); - - expect(fillHolesWorkerMock).toHaveBeenCalledTimes(1); - expect(fillHolesWorkerMock.mock.calls[0][0]).toMatchObject({ - axis: 2, - sliceIndex: 2, - }); - }); -}); diff --git a/src/store/__tests__/image-stats.spec.ts b/src/store/__tests__/image-stats.spec.ts new file mode 100644 index 000000000..1a3515016 --- /dev/null +++ b/src/store/__tests__/image-stats.spec.ts @@ -0,0 +1,165 @@ +import { MessageChannel } from 'node:worker_threads'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, disposePinia, setActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import * as Comlink from 'comlink'; +import { histogram } from '@/src/utils/histogram'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useImageStatsStore } from '@/src/store/image-stats'; +import { useMessageStore } from '@/src/store/messages'; +import { seatImage } from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +// Real Comlink messages and histogram results, with completion controlled at +// the browser Worker boundary because the unit environment has no Workers. +class HistogramEndpoint { + channel = new MessageChannel(); + + postMessage = this.channel.port1.postMessage.bind(this.channel.port1); + + addEventListener = this.channel.port1.addEventListener.bind( + this.channel.port1 + ); + + removeEventListener = this.channel.port1.removeEventListener.bind( + this.channel.port1 + ); + + finish!: (error?: Error) => void; + + started = false; + + terminate = vi.fn(() => { + this.channel.port1.close(); + this.channel.port2.close(); + }); + + constructor() { + const completion = new Promise((resolve, reject) => { + this.finish = (error) => (error ? reject(error) : resolve()); + }); + Comlink.expose( + { + histogram: async (...args: Parameters) => { + this.started = true; + await completion; + return histogram(...args); + }, + }, + this.channel.port2 + ); + this.channel.port1.start(); + } +} + +describe('image statistics worker ownership', () => { + let pinia: ReturnType; + let workers: HistogramEndpoint[]; + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + workers = []; + vi.stubGlobal( + 'Worker', + class extends HistogramEndpoint { + constructor() { + super(); + workers.push(this); + } + } + ); + useImageStatsStore(); + }); + + afterEach(async () => { + const cache = useImageCacheStore(); + [...cache.imageIds].forEach(cache.removeImage); + await nextTick(); + workers.forEach((worker) => worker.terminate()); + disposePinia(pinia); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function startImage(id: string, offset = 0) { + await seatImage(id, { + dimensions: [8, 8, 16], + values: Int16Array.from( + { length: 1024 }, + (_, index) => (index % 512) + offset + ), + }); + const worker = workers[workers.length - 1]; + await vi.waitFor(() => expect(worker.started).toBe(true)); + return worker; + } + + async function expectRanges(id: string, offset = 0) { + await vi.waitFor(() => { + expect(useImageStatsStore().getAutoRangeValues(id)).toEqual({ + FullRange: [offset, offset + 511], + LowContrast: [offset + 5, offset + 507], + MediumContrast: [offset + 10, offset + 502], + HighContrast: [offset + 25, offset + 487], + }); + }); + } + + it('reclaims each completed worker and preserves repeated auto ranges', async () => { + for (let cycle = 0; cycle < 4; cycle++) { + const id = `image-${cycle}`; + const worker = await startImage(id, cycle * 100 - 300); + expect(worker.terminate).not.toHaveBeenCalled(); + worker.finish(); + await expectRanges(id, cycle * 100 - 300); + expect(worker.terminate).toHaveBeenCalledExactlyOnceWith(); + useImageCacheStore().removeImage(id); + await nextTick(); + expect(useImageStatsStore().stats[id]).toBeUndefined(); + } + expect(useMessageStore().messages).toEqual([]); + }); + + it('reclaims a rejected worker while other calculations and later loads succeed', async () => { + const failed = await startImage('failed'); + const healthy = await startImage('healthy', -1000); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + + failed.finish(new Error('Histogram failed')); + await vi.waitFor(() => { + expect(useMessageStore().messages).toHaveLength(1); + }); + expect(useMessageStore().messages[0].title).toBe( + 'Auto range computation failed for image failed' + ); + expect(errors).toHaveBeenCalled(); + expect(failed.terminate).toHaveBeenCalledExactlyOnceWith(); + expect(healthy.terminate).not.toHaveBeenCalled(); + expect(useImageStatsStore().getAutoRangeValues('failed')).toEqual({}); + + healthy.finish(); + await expectRanges('healthy', -1000); + expect(healthy.terminate).toHaveBeenCalledExactlyOnceWith(); + const later = await startImage('later', 1000); + later.finish(); + await expectRanges('later', 1000); + expect(later.terminate).toHaveBeenCalledExactlyOnceWith(); + }); + + it('finishes a removed image without restoring statistics or stopping a peer', async () => { + const removed = await startImage('removed'); + const healthy = await startImage('healthy'); + useImageCacheStore().removeImage('removed'); + await nextTick(); + removed.finish(); + await vi.waitFor(() => { + expect(removed.terminate).toHaveBeenCalledExactlyOnceWith(); + }); + expect(useImageStatsStore().stats.removed).toBeUndefined(); + expect(healthy.terminate).not.toHaveBeenCalled(); + healthy.finish(); + await expectRanges('healthy'); + expect(healthy.terminate).toHaveBeenCalledExactlyOnceWith(); + expect(useMessageStore().messages).toEqual([]); + }); +}); diff --git a/src/store/__tests__/legacyManifestSegmentGroups.spec.ts b/src/store/__tests__/legacyManifestSegmentGroups.spec.ts index c66fd86bf..224803fde 100644 --- a/src/store/__tests__/legacyManifestSegmentGroups.spec.ts +++ b/src/store/__tests__/legacyManifestSegmentGroups.spec.ts @@ -1,12 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; -import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; -import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { makeSpecImage } from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDatasetStore } from '@/src/store/datasets'; import { ManifestSchema } from '@/src/io/state-file/schema'; -import { resolveArtifactRestoreSources } from '@/src/io/import/processors/restoreStateFile'; +import { migrateManifest } from '@/src/io/state-file/migrations'; +import { resolveLabelmapSources } from '@/src/io/import/labelmapImports'; +import { listMasks } from '@/src/segmentation/model'; // --------------------------------------------------------------------------- // Backward compatibility: manifests saved before `datasets` existed (and @@ -18,17 +20,6 @@ import { resolveArtifactRestoreSources } from '@/src/io/import/processors/restor // dataset is removed after conversion. // --------------------------------------------------------------------------- -const ioMocks = vi.hoisted(() => ({ - readImage: vi.fn(), - writeSegmentation: vi.fn(async () => new Uint8Array([1, 2, 3])), -})); - -// eslint-disable-next-line no-restricted-syntax -- ITK-wasm image IO has no counterpart in the node test environment -vi.mock('@/src/io/readWriteImage', () => ({ - readImage: ioMocks.readImage, - writeSegmentation: ioMocks.writeSegmentation, -})); - const segments = { order: [1], byValue: { @@ -41,42 +32,35 @@ const segments = { }, }; -// No `datasets` root: the legacy composed shape. -const legacyManifest = ManifestSchema.parse({ - version: '6.4.0', - dataSources: [ - { id: 1, type: 'uri', uri: 'https://ex/ct.nrrd', name: 'CT Chest' }, - { id: 3, type: 'uri', uri: 'https://ex/tumor.seg.nrrd', name: 'Tumor' }, - ], - segmentGroups: [ - { - id: 'sg-tumor', - dataSourceId: 3, - metadata: { name: 'sg-tumor', parentImage: '1', segments }, - }, - ], -}); - -function makeImage() { - const image = vtkImageData.newInstance(); - image.setDimensions([4, 4, 4]); - image.getPointData().setScalars( - vtkDataArray.newInstance({ - numberOfComponents: 1, - values: new Uint8Array(4 * 4 * 4), +// No `datasets` root: the legacy composed shape, read through the migration +// the import path runs before anything touches a store. +const legacyManifest = ManifestSchema.parse( + migrateManifest( + JSON.stringify({ + version: '6.4.0', + dataSources: [ + { id: 1, type: 'uri', uri: 'https://ex/ct.nrrd', name: 'CT Chest' }, + { id: 3, type: 'uri', uri: 'https://ex/tumor.seg.nrrd', name: 'Tumor' }, + ], + segmentGroups: [ + { + id: 'sg-tumor', + dataSourceId: 3, + metadata: { name: 'sg-tumor', parentImage: '1', segments }, + }, + ], }) - ); - image.computeTransforms(); - return image; -} + ) +); + +const makeImage = () => makeSpecImage(); const seatImage = (id: string, name: string) => useImageCacheStore().addVTKImageData(makeImage(), name, { id }); -describe('segmentGroups.deserialize — legacy manifests without `datasets`', () => { +describe('migrated legacy manifests without `datasets`', () => { beforeEach(() => { setActivePinia(createPinia()); - ioMocks.readImage.mockReset(); }); it('attaches a path-less group via the dataset covering its dataSourceId', async () => { @@ -84,22 +68,54 @@ describe('segmentGroups.deserialize — legacy manifests without `datasets`', () seatImage('store-seg', 'Tumor'); const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); - const store = useSegmentGroupStore(); - const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( - legacyManifest, - [], + const store = useSegmentationStore(); + const { restoredImportIds: restored, skipped } = await store.deserialize({ + manifest: legacyManifest, + stateFiles: [], // Restore keys every fallback dataset by its stringified source id. - { '1': 'store-ct', '3': 'store-seg' }, - resolveArtifactRestoreSources(legacyManifest) - ); + dataIDMap: { '1': 'store-ct', '3': 'store-seg' }, + segmentIdMap: useSegmentStore().deserialize(legacyManifest), + labelmapSources: resolveLabelmapSources(legacyManifest), + }); expect(skipped).toEqual([]); - expect(idMap['sg-tumor']).toBeDefined(); - expect( - Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') - ).toBe(true); + expect(restored.has('sg-tumor')).toBe(true); // The consumed artifact dataset is removed after conversion. expect(removeSpy).toHaveBeenCalledTimes(1); expect(removeSpy).toHaveBeenCalledWith('store-seg'); + + // The migrated descriptor restored as a segment with its own bounded mask. + const segmentation = store.getSegmentationForImage('store-ct')!; + expect( + listMasks(segmentation).map((segment) => ({ + name: useSegmentStore().segments.appearanceOf(segment.segmentId).name, + bound: !!segment.representations.labelmap, + })) + ).toEqual([{ name: 'Tumor', bound: true }]); + }); + + // The split reuses the segment the manifest named only while that segment + // holds no mask on this image, so the migrated masks must be detached first. + // Splitting before the detach mints a suffixed duplicate instead. + it('reuses the migrated segment rather than minting a second one', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('store-seg', 'Tumor'); + + const store = useSegmentationStore(); + await store.deserialize({ + manifest: legacyManifest, + stateFiles: [], + dataIDMap: { '1': 'store-ct', '3': 'store-seg' }, + segmentIdMap: useSegmentStore().deserialize(legacyManifest), + labelmapSources: resolveLabelmapSources(legacyManifest), + }); + + const names = useSegmentStore().segments.segmentList.value.map( + (segment) => segment.name + ); + expect(names).toEqual(['Tumor']); + expect(listMasks(store.getSegmentationForImage('store-ct')!)).toHaveLength( + 1 + ); }); }); diff --git a/src/store/__tests__/paintProcess.spec.ts b/src/store/__tests__/paintProcess.spec.ts deleted file mode 100644 index b40d0053f..000000000 --- a/src/store/__tests__/paintProcess.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { setActivePinia, createPinia } from 'pinia'; -import { createApp } from 'vue'; -import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { PaintMode } from '@/src/core/tools/paint'; -import { CorePiniaProviderPlugin } from '@/src/core/provider'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { usePaintProcessStore } from '@/src/store/tools/paintProcess'; - -function makeLabelMap(values: Uint8Array) { - const labelMap = vtkLabelMap.newInstance(); - labelMap.setDimensions([values.length, 1, 1]); - labelMap.getPointData().setScalars( - vtkDataArray.newInstance({ - numberOfComponents: 1, - values, - }) - ); - labelMap.computeTransforms(); - return labelMap; -} - -function getScalars(labelMap: vtkLabelMap) { - return Array.from(labelMap.getPointData().getScalars().getData()); -} - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function addTestSegmentGroup(values = new Uint8Array([0, 0])) { - const segmentGroupStore = useSegmentGroupStore(); - const labelMap = makeLabelMap(values); - const groupId = segmentGroupStore.addLabelmap(labelMap, { - name: 'Test group', - parentImage: 'image-1', - segments: { - order: [1], - byValue: { - 1: { - value: 1, - name: 'Segment 1', - color: [255, 0, 0, 255], - visible: true, - locked: false, - }, - }, - }, - }); - - return { groupId, labelMap }; -} - -describe('Paint process store', () => { - beforeEach(() => { - const pinia = createPinia().use(CorePiniaProviderPlugin()); - createApp({}).use(pinia); - setActivePinia(pinia); - }); - - it('opens process controls without changing the paint interaction mode', () => { - const paintStore = usePaintToolStore(); - - paintStore.setMode(PaintMode.Erase); - paintStore.setProcessControlsOpen(true); - - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.Erase); - expect(paintStore.activePaintMode).toBe(PaintMode.Erase); - expect(paintStore.isPaintingModeActive).toBe(true); - }); - - it('uses process interaction mode only while previewing', async () => { - const paintStore = usePaintToolStore(); - const processStore = usePaintProcessStore(); - const { groupId, labelMap } = addTestSegmentGroup(); - - paintStore.setMode(PaintMode.Erase); - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - - expect(paintStore.processControlsOpen).toBe(false); - - await processStore.startProcess( - groupId, - async () => new Uint8Array([2, 2]) - ); - - expect(processStore.processState.step).toBe('previewing'); - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.Process); - expect(paintStore.activePaintMode).toBe(PaintMode.Erase); - expect(paintStore.isPaintingModeActive).toBe(false); - expect(getScalars(labelMap)).toEqual([2, 2]); - - processStore.confirmProcess(); - - expect(processStore.processState.step).toBe('start'); - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.Erase); - expect(paintStore.activePaintMode).toBe(PaintMode.Erase); - expect(paintStore.isPaintingModeActive).toBe(true); - }); - - it('restores the paint interaction mode when preview is canceled', async () => { - const paintStore = usePaintToolStore(); - const processStore = usePaintProcessStore(); - const { groupId, labelMap } = addTestSegmentGroup(); - - paintStore.setMode(PaintMode.CirclePaint); - paintStore.setProcessControlsOpen(true); - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - - await processStore.startProcess( - groupId, - async () => new Uint8Array([3, 3]) - ); - - processStore.cancelProcess(); - - expect(processStore.processState.step).toBe('start'); - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.CirclePaint); - expect(paintStore.activePaintMode).toBe(PaintMode.CirclePaint); - expect(paintStore.isPaintingModeActive).toBe(true); - expect(getScalars(labelMap)).toEqual([0, 0]); - }); - - it('ignores stale async results after a newer process starts', async () => { - const paintStore = usePaintToolStore(); - const { groupId, labelMap } = addTestSegmentGroup(); - - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - paintStore.activeMode = PaintMode.Process; - const processStore = usePaintProcessStore(); - - const first = deferred(); - const second = deferred(); - const firstRun = processStore.startProcess(groupId, () => first.promise); - const secondRun = processStore.startProcess(groupId, () => second.promise); - - first.resolve(new Uint8Array([9, 9])); - await firstRun; - - expect(processStore.processState.step).toBe('computing'); - expect(getScalars(labelMap)).toEqual([0, 0]); - - second.resolve(new Uint8Array([2, 2])); - await secondRun; - - expect(processStore.processState.step).toBe('previewing'); - expect(getScalars(labelMap)).toEqual([2, 2]); - }); -}); diff --git a/src/store/__tests__/rulers.spec.ts b/src/store/__tests__/rulers.spec.ts index f47d88284..fd519bd82 100644 --- a/src/store/__tests__/rulers.spec.ts +++ b/src/store/__tests__/rulers.spec.ts @@ -1,6 +1,15 @@ import { describe, it, beforeEach, expect } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; +import { mintSegment } from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { nextTick } from 'vue'; +import { + STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + TOOL_COLORS, +} from '@/src/config'; +import { cssColorToRGBA, rgbaToCssColor } from '@/src/segmentation/color'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useRulerStore } from '@/src/store/tools/rulers'; import { Ruler } from '@/src/types/ruler'; import { RequiredWithPartial } from '@/src/types'; @@ -8,15 +17,7 @@ import { ToolID } from '@/src/types/annotation-tool'; function createRuler(): RequiredWithPartial< Ruler, - | 'id' - | 'color' - | 'strokeWidth' - | 'label' - | 'labelName' - | 'hidden' - | 'metadata' - | 'frame' - | 'source' + 'id' | 'segmentId' | 'hidden' | 'metadata' | 'frame' | 'source' > { return { firstPoint: [1, 1, 1], @@ -84,3 +85,130 @@ describe('Ruler store', () => { // TODO testing jumpToRuler requires store integration // TODO testing (de)serialize requires store integration }); + +describe('Ruler segment segments', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + // Adding selects, so this is the segment a new ruler picks up. + const seedSegment = (store: ReturnType) => + store.segments.addSegment({ name: 'Tumor' }); + + it('draws out of the shared registry', () => { + const store = useRulerStore(); + const segmentId = useSegmentStore().segments.addSegment({ name: 'Tumor' }); + + expect(store.segments.getSegment(segmentId)?.name).toBe('Tumor'); + expect(store.segments.appearanceOf(segmentId)).toMatchObject({ + name: 'Tumor', + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + }); + }); + + it('adds a ruler carrying the type it was given', () => { + const store = useRulerStore(); + const segmentId = store.segments.addSegment({ name: 'Tumor' }); + + const id = store.addRuler({ ...createRuler(), segmentId }); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + expect(store.appearanceOfTool(id).name).toBe('Tumor'); + }); + + it('defaults a new ruler to the selected type', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + + const id = store.addRuler(createRuler()); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + }); + + it('colors a new segment from the tool palette', () => { + const store = useRulerStore(); + + const id = seedSegment(store); + + expect(store.segments.appearanceOf(id).cssColor).toBe( + rgbaToCssColor(cssColorToRGBA(TOOL_COLORS[0])) + ); + expect(store.segments.selectedSegmentId.value).toBe(id); + }); + + it('shows a rename on the rulers that reference the type', async () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + const id = store.addRuler({ ...createRuler(), segmentId }); + + store.segments.updateSegment(segmentId, { + name: 'Lesion', + color: cssColorToRGBA('blue'), + }); + await nextTick(); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + expect(store.appearanceOfTool(id)).toMatchObject({ + name: 'Lesion', + cssColor: rgbaToCssColor(cssColorToRGBA('blue')), + }); + }); + + it('takes the rulers of a deleted type with it', async () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + const id = store.addRuler({ ...createRuler(), segmentId }); + + store.segments.deleteSegment(segmentId); + await nextTick(); + + expect(store.rulerByID[id]).toBeUndefined(); + }); + + it('binds a name to the type already carrying it', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + + const id = store.segments.segmentNamed('Tumor'); + + expect(store.segments.segmentList.value).toHaveLength(1); + expect(id).toBe(segmentId); + }); + + it('selects a type, including back to unset', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + + store.segments.selectSegment(undefined); + expect(store.segments.selectedSegmentId.value).toBeUndefined(); + + store.segments.selectSegment(segmentId); + expect(store.segments.selectedSegmentId.value).toBe(segmentId); + }); + + it('names its segments in the shared list, not one of its own', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + store.addRuler({ ...createRuler(), segmentId }); + + const manifest = { tools: {} } as any; + store.serialize({ zip: {} as any, manifest }); + const { tools } = store.serializeTools(); + + // The shared store writes the segments; a ruler only references one. + expect(manifest.rulerSegments).toBeUndefined(); + expect(tools[0].segmentId).toBe(segmentId); + }); + + it('shares the registry with the masks painted on an image', () => { + const store = useRulerStore(); + const segmentation = useSegmentationStore().ensureSegmentationForImage('4'); + + const painted = useSegmentationStore().createMask( + segmentation.id, + mintSegment({ name: 'Tumor' }) + ); + + expect(store.segments.getSegment(painted.segmentId)?.name).toBe('Tumor'); + }); +}); diff --git a/src/store/__tests__/views.spec.ts b/src/store/__tests__/views.spec.ts index 41d6057fb..aee365ad2 100644 --- a/src/store/__tests__/views.spec.ts +++ b/src/store/__tests__/views.spec.ts @@ -26,7 +26,7 @@ describe('View store', () => { expect(store.activeView).toBe(store.visibleViews[0].id); }); - it('preserves stored view types when cine data is attached', () => { + it('preserves stored view segments when cine data is attached', () => { const store = useViewStore(); const storedTypes = store.visibleViews.map((view) => view.type); diff --git a/src/store/datasets.ts b/src/store/datasets.ts index e74af551c..11472710f 100644 --- a/src/store/datasets.ts +++ b/src/store/datasets.ts @@ -251,7 +251,7 @@ export const useDatasetStore = defineStore('dataset', () => { const remove = (id: string | null) => { if (!id) return; // Prune the provenance entry too, or `serialize` re-emits the removed - // dataset (e.g. the temp dataset a segment group consumed at restore) as a + // dataset (e.g. the temp dataset a segmentation consumed at restore) as a // dangling manifest entry that a later restore fetches as a visible // Anonymous volume. loadedData.value = loadedData.value.filter((d) => d.dataID !== id); diff --git a/src/store/image-stats.ts b/src/store/image-stats.ts index b00cddd40..6f40bc3b6 100644 --- a/src/store/image-stats.ts +++ b/src/store/image-stats.ts @@ -54,16 +54,19 @@ async function computeAutoRangeValues(imageData: vtkImageData) { return {}; } - const worker = Comlink.wrap( - new Worker(new URL('@/src/utils/histogram.worker.ts', import.meta.url), { - type: 'module', - }) - ); - const { min, max } = getAllComponentRange(scalars); const scalarData = scalars.getData() as number[]; - const hist = await worker.histogram(scalarData, [min, max], WL_HIST_BINS); - worker[Comlink.releaseProxy](); + const worker = new Worker( + new URL('@/src/utils/histogram.worker.ts', import.meta.url), + { type: 'module' } + ); + const remote = Comlink.wrap(worker); + const hist = await remote + .histogram(scalarData, [min, max], WL_HIST_BINS) + .finally(() => { + remote[Comlink.releaseProxy](); + worker.terminate(); + }); const cumulativeHist: number[] = []; hist.reduce((acc, val) => { diff --git a/src/store/load-data.ts b/src/store/load-data.ts index c1c14e67f..ffedf96f9 100644 --- a/src/store/load-data.ts +++ b/src/store/load-data.ts @@ -99,11 +99,11 @@ const useLoadDataStore = defineStore('loadData', () => { const { startLoading, stopLoading, setError, isLoading } = useLoadingNotifications(); - const segmentGroupExtension = ref(''); + const segmentationExtension = ref(''); const layerExtension = ref(''); return { - segmentGroupExtension, + segmentationExtension, layerExtension, isLoading, startLoading, diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts deleted file mode 100644 index 98905ef69..000000000 --- a/src/store/segmentGroups.ts +++ /dev/null @@ -1,802 +0,0 @@ -import { computed, reactive, ref, toRaw, watch } from 'vue'; -import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; -import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; -import type { TypedArray } from '@kitware/vtk.js/types'; -import { defineStore } from 'pinia'; -import { normalize } from '@/src/utils/path'; -import { useIdStore } from '@/src/store/id'; -import { onImageDeleted } from '@/src/composables/onImageDeleted'; -import { normalizeForStore, removeFromArray } from '@/src/utils'; -import { SegmentMask } from '@/src/types/segment'; -import type { ProcessingResultSource } from '@/src/types'; -import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config'; -import { readImage, writeSegmentation } from '@/src/io/readWriteImage'; -import { - parseSegNrrdMetadata, - overlaySegmentMetadata, -} from '@/src/io/segNrrdMetadata'; -import type { ArtifactRestoreSource } from '@/src/io/import/processors/restoreStateFile'; -import { - type DataSelection, - getImage, - isRegularImage, -} from '@/src/utils/dataSelection'; -import vtkImageExtractComponents from '@/src/utils/imageExtractComponentsFilter'; -import { useImageCacheStore } from '@/src/store/image-cache'; -import DicomChunkImage from '@/src/core/streaming/dicomChunkImage'; -import { useDICOMStore } from '@/src/store/datasets-dicom'; -import vtkLabelMap from '../vtk/LabelMap'; -import { - StateFile, - Manifest, - SegmentGroupMetadata, - SegmentGroup, -} from '../io/state-file/schema'; -import { makeSegmentGroupArchivePath } from '../io/state-file/segmentGroupArchivePath'; -import { FileEntry } from '../io/types'; -import { ensureSameSpace } from '../io/resample/resample'; -import { untilLoaded } from '../composables/untilLoaded'; -import { useDatasetStore } from './datasets'; - -const LabelmapArrayType = Uint8Array; -export type LabelmapArrayType = Uint8Array; - -export const LABELMAP_BACKGROUND_VALUE = 0; -export const makeDefaultSegmentName = (value: number) => `Segment ${value}`; -export const makeDefaultSegmentGroupName = (baseName: string, index: number) => - `Segment Group ${index} for ${baseName}`; -const numberer = (index: number) => (index <= 1 ? '' : `${index}`); // start numbering at 2 - -export type SegmentGroupMetadata = { - name: string; - parentImage: string; - segments: { - order: number[]; - byValue: Record; - }; - // Provenance of a job-produced group; absent on hand-painted ones. - source?: ProcessingResultSource; -}; - -export function createLabelmapFromImage(imageData: vtkImageData) { - const points = new LabelmapArrayType(imageData.getNumberOfPoints()); - const labelmap = vtkLabelMap.newInstance( - imageData.get('spacing', 'origin', 'direction') - ); - labelmap.getPointData().setScalars( - vtkDataArray.newInstance({ - numberOfComponents: 1, - values: points, - }) - ); - labelmap.setDimensions(imageData.getDimensions()); - labelmap.computeTransforms(); - - return labelmap; -} - -function convertToUint8(array: number[] | TypedArray): Uint8Array { - const uint8Array = new Uint8Array(array.length); - for (let i = 0; i < array.length; i++) { - const value = array[i]; - uint8Array[i] = value < 0 || value > 255 ? 0 : value; - } - return uint8Array; -} - -function getLabelMapScalars(imageData: vtkImageData) { - const scalars = imageData.getPointData().getScalars(); - let values = scalars.getData(); - - if (!(values instanceof LabelmapArrayType)) { - values = convertToUint8(values); - } - - return vtkDataArray.newInstance({ - numberOfComponents: scalars.getNumberOfComponents(), - values, - }); -} - -export function toLabelMap(imageData: vtkImageData) { - const labelmap = vtkLabelMap.newInstance( - imageData.get('spacing', 'origin', 'direction', 'extent', 'dataDescription') - ); - - labelmap.setDimensions(imageData.getDimensions()); - labelmap.computeTransforms(); - - // outline rendering only supports UInt8Array image types - const scalars = getLabelMapScalars(imageData); - labelmap.getPointData().setScalars(scalars); - - return labelmap; -} - -export function extractEachComponent(input: vtkImageData) { - const numComponents = input - .getPointData() - .getScalars() - .getNumberOfComponents(); - const extractComponentsFilter = vtkImageExtractComponents.newInstance(); - extractComponentsFilter.setInputData(input); - return Array.from({ length: numComponents }, (_, i) => { - extractComponentsFilter.setComponents([i]); - extractComponentsFilter.update(); - return extractComponentsFilter.getOutputData() as vtkImageData; - }); -} - -export const useSegmentGroupStore = defineStore('segmentGroup', () => { - type _This = ReturnType; - const imageCacheStore = useImageCacheStore(); - - const dataIndex = reactive>(Object.create(null)); - const metadataByID = reactive>( - Object.create(null) - ); - const orderByParent = ref>(Object.create(null)); - - /** - * Gets the metadata for a labelmap. - * @param segmentGroupID - * @param segmentValue - */ - function getMetadata(segmentGroupID: string) { - if (!(segmentGroupID in metadataByID)) - throw new Error('No such labelmap ID'); - return metadataByID[segmentGroupID]; - } - - /** - * Gets a segment. - * @param segmentGroupID - * @param segmentValue - * @returns - */ - function getSegment(segmentGroupID: string, segmentValue: number) { - const metadata = getMetadata(segmentGroupID); - if (!(segmentValue in metadata.segments.byValue)) - throw new Error('No such segment'); - return metadata.segments.byValue[segmentValue]; - } - - /** - * Validates that a segment does not violate constraints. - * - * Assumes that the given segment is not yet part of the labelmap segments. - * @param segmentGroupID - * @param segment - */ - function validateSegment(segmentGroupID: string, segment: SegmentMask) { - return ( - // cannot be zero (background) - segment.value !== 0 && - // cannot already exist - !(segment.value in getMetadata(segmentGroupID).segments.byValue) - ); - } - - /** - * Adds a given image + metadata as a labelmap. - */ - function addLabelmap( - this: _This, - labelmap: vtkLabelMap, - metadata: SegmentGroupMetadata - ) { - const id = useIdStore().nextId(); - - dataIndex[id] = labelmap; - metadataByID[id] = metadata; - orderByParent.value[metadata.parentImage] ??= []; - orderByParent.value[metadata.parentImage].push(id); - - return id; - } - - // Used for constructing labelmap names in newLabelmapFromImage. - // Cleared by the onImageDeleted cascade below. - const nextDefaultIndex: Record = Object.create(null); - - function pickUniqueName( - formatName: (index: number) => string, - parentID: string - ) { - const existingNames = new Set( - Object.values(metadataByID).map((meta) => meta.name) - ); - let name = ''; - do { - const nameIndex = nextDefaultIndex[parentID] ?? 1; - nextDefaultIndex[parentID] = nameIndex + 1; - name = formatName(nameIndex); - } while (existingNames.has(name)); - return name; - } - - /** - * Creates a new labelmap entry from a parent/source image. - */ - function newLabelmapFromImage(this: _This, parentID: string) { - const imageData = imageCacheStore.getVtkImageData(parentID); - if (!imageData) { - return null; - } - const baseName = - imageCacheStore.getImageMetadata(parentID)?.name ?? '(no name)'; - - const labelmap = createLabelmapFromImage(imageData); - - const { order, byKey } = normalizeForStore( - structuredClone(DEFAULT_SEGMENT_MASKS), - 'value' - ); - - const name = pickUniqueName( - (index: number) => makeDefaultSegmentGroupName(baseName, index), - parentID - ); - - return addLabelmap.call(this, labelmap, { - name, - parentImage: parentID, - segments: { order, byValue: byKey }, - }); - } - - /** - * Deletes a labelmap. - */ - function removeGroup(id: string) { - if (!(id in dataIndex)) return; - const { parentImage } = metadataByID[id]; - removeFromArray(orderByParent.value[parentImage], id); - delete dataIndex[id]; - delete metadataByID[id]; - } - - let nextColorIndex = 0; - function getNextColor() { - const color = CATEGORICAL_COLORS[nextColorIndex]; - nextColorIndex = (nextColorIndex + 1) % CATEGORICAL_COLORS.length; - return [...color, 255] as const; - } - - // `imageId` may be undefined when the labelmap's bytes did not arrive - // through a loaded image dataset (a zip-restored group). DICOM-SEG decoding - // still requires a source image, while file-header metadata can be supplied - // directly for archive-backed images. - async function decodeSegments( - imageId: DataSelection | undefined, - image: vtkLabelMap, - component = 0, - headerMetadata?: Map - ) { - const dicomStore = useDICOMStore(); - if ( - imageId !== undefined && - !isRegularImage(imageId) && - dicomStore.volumeInfo[imageId]?.kind !== 'cine' - ) { - await untilLoaded(imageId); - - const chunkImage = imageCacheStore.imageById[imageId] as DicomChunkImage; - if (chunkImage.getModality() === 'SEG' && chunkImage.segBuildInfo) { - const segments = chunkImage.segBuildInfo.segmentAttributes[component]; - return segments.map((segment) => ({ - value: segment.labelID, - name: segment.SegmentLabel, - color: [...segment.recommendedDisplayRGBValue, 255], - visible: true, - })); - } - } - - // Slicer-convention `.seg.nrrd` embedded metadata: a labelmap - // produced by a backend CLI carries its real segment names/colors in the - // NRRD header, captured onto the loaded image at import. - // - // MERGE, not replace: the distinct nonzero voxel values are the spine, so - // a labelled voxel with NO `Segment{N}_*` block still gets a default, - // visible, manageable segment instead of being dropped. Embedded - // name/color/visibility are overlaid onto the matching `LabelValue == voxel - // value`; undescribed values keep their default. - const embedded = - headerMetadata ?? - (imageId !== undefined - ? imageCacheStore.imageById[imageId]?.headerMetadata - : undefined); - const described = embedded ? parseSegNrrdMetadata(embedded) : undefined; - - // Distinct nonzero voxel values, ascending — the segment spine. - // Labelmap scalars are Uint8Array by construction (both callers pass a - // `toLabelMap` result, which forces UInt8), so a fixed 256-slot presence map - // gives one branch-free typed-array write per voxel on the hot path, and the - // 0..255 sweep is already ascending (no Set, no per-voxel Number(), no sort). - const voxelValues = image.getPointData().getScalars().getData(); - const present = new Uint8Array(256); - for (let index = 0; index < voxelValues.length; index += 1) { - present[voxelValues[index]] = 1; - } - const values: number[] = []; - for (let value = 0; value < present.length; value += 1) { - if (present[value] && value !== LABELMAP_BACKGROUND_VALUE) - values.push(value); - } - - return overlaySegmentMetadata(values, described, (value) => ({ - value, - name: makeDefaultSegmentName(value), - color: [...getNextColor()], - visible: true, - })); - } - - /** - * Converts an image to a labelmap. - * - * Returns the created segment-group id(s) — one per component of the source - * image (one for the common single-component case). Awaits the per-component - * adds so the caller can act on the created groups synchronously afterwards - * (corroboration/present + descriptor application key off the - * returned ids rather than racing `orderByParent`). - */ - async function convertImageToLabelmap( - imageID: DataSelection, - parentID: DataSelection, - source?: SegmentGroupMetadata['source'] - ): Promise { - if (imageID === parentID) - throw new Error('Cannot convert an image to be a labelmap of itself'); - - await untilLoaded(imageID); - - const [childImage, parentImage] = await Promise.all( - [imageID, parentID].map(getImage) - ); - - if (!childImage || !parentImage) - throw new Error('Image and/or parent datasets do not exist'); - - const intersects = vtkBoundingBox.intersects( - parentImage.getBounds(), - childImage.getBounds() - ); - if (!intersects) { - throw new Error( - 'Segment group and parent image bounds do not intersect. So there is no overlap in physical space.' - ); - } - - const baseName = - imageCacheStore.getImageMetadata(imageID)?.name ?? '(no name)'; - - const componentCount = childImage - .getPointData() - .getScalars() - .getNumberOfComponents(); - // for each component, create create new vtkImageData with just one component, pulled from each component of childImage - const images = - componentCount === 1 ? [childImage] : extractEachComponent(childImage); - - return Promise.all( - images.map(async (image, component) => { - const matchingParentSpace = await ensureSameSpace( - parentImage, - image, - true - ); - const labelmapImage = toLabelMap(matchingParentSpace); - - const segments = await decodeSegments( - imageID, - labelmapImage, - component - ); - const { order, byKey } = normalizeForStore(segments, 'value'); - const segmentGroupStore = useSegmentGroupStore(); - - const name = pickUniqueName( - (index: number) => `${baseName} ${numberer(index)}`, - parentID - ); - const id = segmentGroupStore.addLabelmap(labelmapImage, { - name, - parentImage: parentID, - segments: { order, byValue: byKey }, - ...(source ? { source } : {}), - }); - return id; - }) - ); - } - - /** - * Updates a labelmap's metadata - * @param segmentGroupID - * @param metadata - */ - function updateMetadata( - segmentGroupID: string, - metadata: Partial - ) { - metadataByID[segmentGroupID] = { - ...getMetadata(segmentGroupID), - ...metadata, - }; - } - - /** - * Creates a new default segment with an unallocated value. - * - * The value picked is the smallest unused value greater than 0. - * @param segmentGroupID - */ - function createNewSegment(segmentGroupID: string): SegmentMask { - const { segments } = getMetadata(segmentGroupID); - - let value = 1; - for (; value <= segments.order.length; value++) { - if (!(value in segments.byValue)) break; - } - - return { - name: makeDefaultSegmentName(value), - value, - color: [...getNextColor()], - visible: true, - locked: false, // default to unlocked - }; - } - - /** - * Adds a segment to a labelmap. - * - * If no segment is provided, a default one is provided. - * Duplicate segment values throw an error. - * @param segmentGroupID - * @param segment - */ - function addSegment(segmentGroupID: string, segment?: SegmentMask) { - const metadata = getMetadata(segmentGroupID); - const seg = segment ?? createNewSegment(segmentGroupID); - if (!validateSegment(segmentGroupID, seg)) - throw new Error('Invalid segment'); - metadata.segments.byValue[seg.value] = seg; - metadata.segments.order.push(seg.value); - return seg; - } - - /** - * Updates a segment's properties. - * - * Does not allow updating the segment value. - * @param segmentGroupID - * @param segmentValue - * @param segmentUpdate - */ - function updateSegment( - segmentGroupID: string, - segmentValue: number, - segmentUpdate: Partial> - ) { - const metadata = getMetadata(segmentGroupID); - const segment = getSegment(segmentGroupID, segmentValue); - metadata.segments.byValue[segmentValue] = { - ...toRaw(segment), - ...segmentUpdate, - }; - } - - /** - * Deletes a segment from a labelmap. - * @param segmentGroupID - * @param segmentValue - */ - function deleteSegment(segmentGroupID: string, segmentValue: number) { - const { segments } = getMetadata(segmentGroupID); - removeFromArray(segments.order, segmentValue); - delete segments.byValue[segmentValue]; - - dataIndex[segmentGroupID].replaceLabelValue( - segmentValue, - LABELMAP_BACKGROUND_VALUE - ); - } - - const saveFormat = ref('vti'); - - /** - * Serializes the store's state. - */ - async function serialize(state: StateFile) { - const { zip } = state; - const usedArchivePaths = new Set(); - - // orderByParent is implicitly preserved based on - // the order of serialized entries. - - const parents = Object.keys(orderByParent.value); - const serialized = parents.flatMap((parentID) => { - const segmentGroupIDs = orderByParent.value[parentID]; - return segmentGroupIDs.map((id) => { - const metadata = metadataByID[id]; - return { - id, - path: makeSegmentGroupArchivePath( - metadata.name, - saveFormat.value, - usedArchivePaths - ), - metadata: { - ...metadata, - parentImage: metadata.parentImage, - }, - }; - }); - }); - - state.manifest.segmentGroups = serialized; - - // save labelmap images - await Promise.all( - serialized.map(async ({ id, path }) => { - const serializedImage = await writeSegmentation( - saveFormat.value, - dataIndex[id], - metadataByID[id] - ); - zip.file(path, serializedImage); - }) - ); - } - - /** - * Rehydrates the store's state. - */ - async function deserialize( - this: _This, - manifest: Manifest, - stateFiles: FileEntry[], - dataIDMap: Record, - // Per-group artifact source, resolved by the restore setup (see - // resolveArtifactRestoreSources in restoreStateFile.ts, the single owner - // of the synthesized-leaf and ownership policy). Mapped through dataIDMap - // here. - artifactSources: Record = {} - ) { - const { segmentGroups } = manifest; - const datasetStore = useDatasetStore(); - - const segmentGroupIDMap: Record = {}; - // Non-silent drops: every group left out of the restore is recorded here - // with a concrete reason so the caller can surface it. - const skipped: Array<{ name: string; reason: string }> = []; - - if (!segmentGroups || segmentGroups.length === 0) { - return { segmentGroupIDMap, skipped }; - } - - // First restore the data, then restore the store. - // This preserves ordering from orderByParent. - - // `path` is authoritative for bytes when present: a re-saved - // zip carries the archive bytes AND the provenance `dataSourceId`, but - // `dataIDMap` is keyed by save-time DATASET ids. Consulting it for a - // path-carrying group could hang restore on a missing key, or worse, - // build the group from an unrelated dataset's voxels and then delete that - // dataset. The `dataSourceId` branch remains for composed manifests, - // whose groups carry no archive bytes — see `artifactStoreId` for how the - // artifact's store id is resolved. - // The temporary artifact dataset id (if any) is resolved by the CALLER - // before the restore `try`, so its cleanup can run unconditionally in a - // `finally` even when this load throws. This function yields the image and - // any file-header metadata needed to reconstruct segment descriptors. - async function loadSegmentGroupImage( - segmentGroup: SegmentGroup, - storeId: string | undefined - ) { - if (segmentGroup.path !== undefined) { - const file = stateFiles.find( - (entry) => entry.archivePath === normalize(segmentGroup.path!) - )?.file; - return readImage(file!); - } - - await untilLoaded(storeId!); - const image = imageCacheStore.getVtkImageData(storeId!); - if (!image) { - throw new Error( - `Could not get image data for dataSourceId ${segmentGroup.dataSourceId}` - ); - } - return { - image, - headerMetadata: imageCacheStore.imageById[storeId!]?.headerMetadata, - }; - } - - // A path-less group's artifact store id: the restore setup already - // resolved which STATE id carries each group's artifact (synthesized leaf - // or covering dataset — a policy owned entirely by restoreStateFile.ts); - // this only maps that id through dataIDMap. - const artifactStoreId = (segmentGroup: SegmentGroup) => { - if (segmentGroup.path !== undefined) return undefined; - const source = artifactSources[segmentGroup.id]; - return source !== undefined ? dataIDMap[source.stateId] : undefined; - }; - - // Resilient restore. Skip BEFORE awaiting anything a - // group whose base image is unresolved, or a path-less group whose artifact - // datasource never materialized — `untilLoaded(undefined)` never times out - // and would hang restore forever. A missing key is knowable up front, so - // the pre-await guard catches it; the per-group settle below is the safety - // net for a fetch/parse failure. Skipped groups drop out of the id map so - // they are left out of the restore. - const attachable = segmentGroups.filter((segmentGroup) => { - if (dataIDMap[segmentGroup.metadata.parentImage] === undefined) { - skipped.push({ - name: segmentGroup.metadata.name, - reason: 'parent image did not load', - }); - return false; - } - if (segmentGroup.path !== undefined) return true; - const hasArtifact = artifactStoreId(segmentGroup) !== undefined; - if (!hasArtifact) { - skipped.push({ - name: segmentGroup.metadata.name, - reason: 'artifact source unavailable', - }); - } - return hasArtifact; - }); - - // Every path-less group's temporary imported artifact must be removed - // exactly ONCE, and only AFTER every group that reads it has settled. - // prepareLeafDataSources dedupes leaves by dataSourceId, so two path-less - // groups referencing the same artifact share ONE temp dataset id; removing - // it inside each group's `finally` let the first group's cleanup starve the - // second group's `getVtkImageData`, dropping it as unreadable. Collect the - // unique ids here and remove them after the `Promise.all` — in a `finally` - // so the cleanup runs even if a group throws unexpectedly. Archive-backed - // groups (path !== undefined) own no temp dataset. - // Collected from EVERY group, not just the attachable ones: a group - // skipped at the parent-image check may still have imported its artifact - // leaf, and that orphan would otherwise sit in the dataset store and - // re-serialize into every future save. - const tempStoreIdsToRemove = new Set( - segmentGroups - .filter( - (segmentGroup) => artifactSources[segmentGroup.id]?.temporary === true - ) - .map(artifactStoreId) - .filter((storeId): storeId is string => storeId !== undefined) - ); - - let labelmapResults; - try { - labelmapResults = await Promise.all( - attachable.map(async (segmentGroup) => { - const storeId = artifactStoreId(segmentGroup); - try { - const { image, headerMetadata } = await loadSegmentGroupImage( - segmentGroup, - storeId - ); - const labelmapImage = toLabelMap(image); - - // Descriptor-less group: `segments` is optional on the wire. When absent, - // build the catalog through the SAME decode/enumerate/default path - // live convertImageToLabelmap uses (voxel enumeration + embedded - // .seg.nrrd metadata overlay + default names/colors) — parity is - // pinned by segmentGroupDescriptorlessParity.spec.ts. - const segments = - segmentGroup.metadata.segments ?? - (await (async () => { - const decoded = await decodeSegments( - storeId, - labelmapImage, - 0, - headerMetadata - ); - const { order, byKey } = normalizeForStore(decoded, 'value'); - return { order, byValue: byKey }; - })()); - - const id = useIdStore().nextId(); - dataIndex[id] = labelmapImage; - return { segmentGroup, id, segments }; - } catch { - // A parse/read failure skips just this group — never rejects the - // whole restore; the survivors still attach. Recorded (not silent) so - // the caller can report it. - skipped.push({ - name: segmentGroup.metadata.name, - reason: 'could not read/parse labelmap', - }); - return undefined; - } - }) - ); - } finally { - tempStoreIdsToRemove.forEach((storeId) => datasetStore.remove(storeId)); - } - - labelmapResults.forEach((result) => { - if (!result) return; - const { segmentGroup, id: newID, segments } = result; - segmentGroupIDMap[segmentGroup.id] = newID; - - const parentImage = dataIDMap[segmentGroup.metadata.parentImage]; - metadataByID[newID] = { ...segmentGroup.metadata, parentImage, segments }; - - orderByParent.value[parentImage] ??= []; - orderByParent.value[parentImage].push(newID); - }); - - return { segmentGroupIDMap, skipped }; - } - - // --- sync segments --- // - - const segmentByGroupID = computed(() => { - return Object.entries(metadataByID).reduce>( - (acc, [id, metadata]) => { - const { - segments: { order, byValue }, - } = metadata; - const segments = order.map((value) => byValue[value]); - return { ...acc, [id]: segments }; - }, - {} - ); - }); - - watch( - segmentByGroupID, - (segsByID) => { - Object.entries(segsByID).forEach(([id, segments]) => { - // ensure segments are not proxies - dataIndex[id].setSegments(toRaw(segments).map((seg) => toRaw(seg))); - }); - }, - { immediate: true } - ); - - // --- handle deletions --- // - - onImageDeleted((deleted) => { - deleted.forEach((parentID) => { - delete nextDefaultIndex[parentID]; - // Iterate a COPY: removeGroup splices the same orderByParent array via - // removeFromArray, so forEaching the live array skips every other group - // when an image has 2+ groups (the normal case once job labelmaps and - // multi-component conversions land). - [...(orderByParent.value[parentID] ?? [])].forEach(removeGroup); - }); - }); - - // --- api --- // - - return { - dataIndex, - metadataByID, - orderByParent, - segmentByGroupID, - saveFormat, - addLabelmap, - newLabelmapFromImage, - removeGroup, - convertImageToLabelmap, - updateMetadata, - addSegment, - getSegment, - updateSegment, - deleteSegment, - serialize, - deserialize, - }; -}); diff --git a/src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts b/src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts new file mode 100644 index 000000000..874e9fac6 --- /dev/null +++ b/src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { useImageCacheStore } from '@/src/store/image-cache'; +import { ManifestSchema } from '@/src/io/state-file/schema'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useRulerStore } from '@/src/store/tools/rulers'; + +// --------------------------------------------------------------------------- +// A state file whose dataset could not be loaded leaves its id out of the +// restore dataIDMap. Seating that image's annotations with imageID undefined +// would give no view anything to draw and make the next save's whole tools +// section fail validation, taking the annotations of the images that did load +// down with it. +// --------------------------------------------------------------------------- + +const LOADED = 'img-loaded'; +const MISSING = 'img-missing'; + +const seat = (id: string) => + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + +const savedRulers = () => { + seat(LOADED); + seat(MISSING); + const store = useRulerStore(); + store.addTool({ + imageID: LOADED, + placing: false, + firstPoint: [1, 1, 1], + secondPoint: [2, 2, 2], + }); + store.addTool({ imageID: MISSING, placing: false }); + return JSON.parse(JSON.stringify(store.serializeTools())); +}; + +describe('restoring annotations whose image did not load', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('skips the annotations of the image that is missing', () => { + const serialized = savedRulers(); + expect(serialized.tools).toHaveLength(2); + + setActivePinia(createPinia()); + seat(LOADED); + const restored = useRulerStore(); + restored.deserializeTools(serialized, { [LOADED]: LOADED }); + + const tools = restored.toolIDs.map((id) => restored.toolByID[id]); + expect(tools.map((tool) => tool.imageID)).toEqual([LOADED]); + expect(tools[0].firstPoint).toEqual([1, 1, 1]); + expect(tools[0].secondPoint).toEqual([2, 2, 2]); + }); + + it('keeps the surviving annotations saveable', () => { + const serialized = savedRulers(); + + setActivePinia(createPinia()); + seat(LOADED); + const restored = useRulerStore(); + restored.deserializeTools(serialized, { [LOADED]: LOADED }); + const resaved = restored.serializeTools(); + + expect(resaved.tools).toHaveLength(1); + expect(resaved.tools[0].imageID).toBe(LOADED); + expect( + ManifestSchema.shape.tools.safeParse({ rulers: resaved }).success + ).toBe(true); + }); + + it('follows the image an annotation was remapped onto', () => { + const serialized = savedRulers(); + + setActivePinia(createPinia()); + seat('new-id'); + const restored = useRulerStore(); + restored.deserializeTools(serialized, { [LOADED]: 'new-id' }); + + expect(restored.toolIDs.map((id) => restored.toolByID[id].imageID)).toEqual( + ['new-id'] + ); + }); + + it('restores nothing when no image came back', () => { + const serialized = JSON.parse( + JSON.stringify( + (() => { + seat(MISSING); + const store = usePolygonStore(); + store.addTool({ imageID: MISSING, placing: false }); + return store.serializeTools(); + })() + ) + ); + + setActivePinia(createPinia()); + const restored = usePolygonStore(); + restored.deserializeTools(serialized, {}); + + expect(restored.toolIDs).toEqual([]); + }); +}); diff --git a/src/store/tools/fillBetween.ts b/src/store/tools/fillBetween.ts deleted file mode 100644 index 32690c815..000000000 --- a/src/store/tools/fillBetween.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineStore } from 'pinia'; -import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; -import { TypedArray } from '@kitware/vtk.js/types'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { morphologicalContourInterpolation } from '@itk-wasm/morphological-contour-interpolation'; - -export const useFillBetweenStore = defineStore('fillBetween', () => { - async function computeAlgorithm( - segImage: vtkLabelMap, - activeSegment: number - ): Promise { - const vtkImage = vtkITKHelper.convertVtkToItkImage(segImage); - const out = await morphologicalContourInterpolation(vtkImage, { - label: activeSegment, - }); - - const vtkOut = vtkITKHelper.convertItkToVtkImage(out.outputImage); - const outputScalars = vtkOut.getPointData().getScalars(); - - return outputScalars.getData() as TypedArray; - } - - return { - computeAlgorithm, - }; -}); diff --git a/src/store/tools/fillHoles.ts b/src/store/tools/fillHoles.ts deleted file mode 100644 index d6c123cee..000000000 --- a/src/store/tools/fillHoles.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import * as Comlink from 'comlink'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { useViewStore } from '@/src/store/views'; -import { useViewSliceStore } from '@/src/store/view-configs/slicing'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import { getImageMetadata } from '@/src/composables/useCurrentImage'; -import { getEffectiveView } from '@/src/core/views/effectiveView'; -import { fillHolesWorker } from '@/src/core/tools/paint/fillHoles.worker'; -import { convertSliceIndex } from '@/src/utils/imageSpace'; -import { getLPSDirections } from '@/src/utils/lps'; - -export enum FillHolesSliceScope { - CurrentSlice = 'currentSlice', - WholeVolume = 'wholeVolume', -} - -export enum FillHolesSegmentScope { - AllSegments = 'allSegments', - SelectedSegment = 'selectedSegment', -} - -type WorkerApi = { - fillHolesWorker: typeof fillHolesWorker; -}; - -let workerInstance: Comlink.Remote | null = null; - -async function getWorker() { - if (!workerInstance) { - const worker = new Worker( - new URL('@/src/core/tools/paint/fillHoles.worker.ts', import.meta.url), - { type: 'module' } - ); - workerInstance = Comlink.wrap(worker); - } - return workerInstance; -} - -export const useFillHolesStore = defineStore('fillHoles', () => { - const sliceScope = ref(FillHolesSliceScope.CurrentSlice); - const segmentScope = ref(FillHolesSegmentScope.AllSegments); - - function setSliceScope(value: FillHolesSliceScope) { - sliceScope.value = value; - } - - function setSegmentScope(value: FillHolesSegmentScope) { - segmentScope.value = value; - } - - async function computeAlgorithm( - segImage: vtkLabelMap, - activeSegment: number - ) { - const viewStore = useViewStore(); - const viewSliceStore = useViewSliceStore(); - const paintStore = usePaintToolStore(); - const segmentGroupStore = useSegmentGroupStore(); - - // Fill Holes works on the slice plane of the 2D view the user is on, so a - // 2D view must be active to know which axis (and slice) to operate on. - const effectiveView = getEffectiveView(viewStore.activeView); - if (effectiveView?.kind !== 'volume2D') { - throw new Error( - 'Fill Holes needs an active 2D slice view. Click a 2D view, then try again.' - ); - } - - const groupId = paintStore.activeSegmentGroupID; - if (!groupId) { - throw new Error('No active segment group'); - } - const metadata = segmentGroupStore.metadataByID[groupId]; - - const parentMetadata = getImageMetadata(metadata.parentImage); - const labelMapLpsOrientation = getLPSDirections(segImage.getDirection()); - const axis = labelMapLpsOrientation[effectiveView.axis]; - - const dimensions = segImage.getDimensions() as [number, number, number]; - const data = segImage.getPointData().getScalars().getData(); - - let sliceIndex: number | undefined; - if (sliceScope.value === FillHolesSliceScope.CurrentSlice) { - const sliceConfig = viewSliceStore.getConfig( - effectiveView.viewInfo.id, - metadata.parentImage - ); - const parentAxis = parentMetadata.lpsOrientation[effectiveView.axis]; - const parentSlice = - sliceConfig?.slice ?? - Math.floor(parentMetadata.dimensions[parentAxis] / 2); - sliceIndex = convertSliceIndex( - parentSlice, - parentMetadata.lpsOrientation, - parentMetadata.indexToWorld, - segImage, - effectiveView.axis - ); - } - - const selectedSegment = - segmentScope.value === FillHolesSegmentScope.SelectedSegment; - const label = selectedSegment ? activeSegment : undefined; - // All-segments mode can fill a hole with any bordering label, so guard - // locked segments from being grown. Selected-segment mode only writes the - // active segment, whose lock is already enforced before the process starts. - const lockedLabels = selectedSegment - ? undefined - : Object.values(metadata.segments.byValue) - .filter((segment) => segment.locked) - .map((segment) => segment.value); - - const worker = await getWorker(); - return worker.fillHolesWorker({ - data, - dimensions, - axis, - sliceIndex, - label, - lockedLabels, - }); - } - - return { - sliceScope, - segmentScope, - setSliceScope, - setSegmentScope, - computeAlgorithm, - }; -}); diff --git a/src/store/tools/gaussianSmooth.ts b/src/store/tools/gaussianSmooth.ts deleted file mode 100644 index 0a2f3fe86..000000000 --- a/src/store/tools/gaussianSmooth.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import * as Comlink from 'comlink'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { gaussianSmoothLabelMapWorker } from '@/src/core/tools/paint/gaussianSmooth.worker'; - -export const DEFAULT_SIGMA = 1.0; -export const MIN_SIGMA = 0.1; -export const MAX_SIGMA = 5.0; - -// Worker management -type WorkerApi = { - gaussianSmoothLabelMapWorker: typeof gaussianSmoothLabelMapWorker; -}; - -let workerInstance: Comlink.Remote | null = null; - -async function getWorker() { - if (!workerInstance) { - // Set up worker with Comlink - const worker = new Worker( - new URL( - '@/src/core/tools/paint/gaussianSmooth.worker.ts', - import.meta.url - ), - { type: 'module' } - ); - workerInstance = Comlink.wrap(worker); - } - return workerInstance; -} - -async function gaussianSmoothLabelMap( - labelMap: vtkLabelMap, - params: { sigma: number; label: number } -) { - const scalars = labelMap.getPointData().getScalars(); - const originalData = scalars.getData(); - const dimensions = labelMap.getDimensions(); - const spacing = labelMap.getSpacing() as [number, number, number]; - - const worker = await getWorker(); - - const workerInput = { - data: originalData, - dimensions, - spacing, - params, - }; - - return worker.gaussianSmoothLabelMapWorker(workerInput); -} - -export const useGaussianSmoothStore = defineStore('gaussianSmooth', () => { - const sigma = ref(DEFAULT_SIGMA); - - function setSigma(value: number) { - sigma.value = Math.max(MIN_SIGMA, Math.min(MAX_SIGMA, value)); - } - - async function computeAlgorithm( - segImage: vtkLabelMap, - activeSegment: number - ) { - const params = { - sigma: sigma.value, - label: activeSegment, - }; - - return gaussianSmoothLabelMap(segImage, params); - } - - return { - sigma, - setSigma, - computeAlgorithm, - }; -}); diff --git a/src/store/tools/index.ts b/src/store/tools/index.ts index 5f0bacdf8..802131322 100644 --- a/src/store/tools/index.ts +++ b/src/store/tools/index.ts @@ -13,6 +13,7 @@ import { plural } from '@/src/utils'; import { AnnotationToolType, IToolStore, Tools } from './types'; import { usePolygonStore } from './polygons'; import { useToolSelectionStore } from './toolSelection'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useViewStore } from '@/src/store/views'; import { EffectiveView, @@ -31,6 +32,16 @@ export function isToolAllowedFor(tool: Tools, effective: EffectiveView | null) { return true; } +// 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 +// made. Seating allocates no voxels. +const SEGMENT_TOOLS = new Set([ + Tools.Paint, + Tools.Rectangle, + Tools.Ruler, + Tools.Polygon, +]); + const activeEffectiveView = () => getEffectiveView(useViewStore().activeView); function coerceForEffective(tool: Tools, effective: EffectiveView | null) { @@ -131,6 +142,9 @@ export const useToolStore = defineStore('tool', () => { } teardownTool(currentTool.value); currentTool.value = coerced; + if (SEGMENT_TOOLS.has(coerced)) { + useSegmentStore().segments.ensureSelectedSegment(); + } } function activateTemporaryCrosshairs() { @@ -173,18 +187,14 @@ export const useToolStore = defineStore('tool', () => { function deserialize( manifest: Manifest, - segmentGroupIDMap: Record, + segmentIdMap: Record, dataIDMap: Record ) { - usePaintToolStore().deserialize(manifest, segmentGroupIDMap); - Object.values(ToolStoreMap) - // paint store uses segmentGroupIDMap - .filter((useStore) => useStore !== usePaintToolStore) .map((useStore) => useStore?.()) .filter((store): store is IToolStore => !!store) .forEach((store) => { - store.deserialize?.(manifest, dataIDMap); + store.deserialize?.(manifest, dataIDMap, segmentIdMap); }); if (manifest.tools?.current) { diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 9abe2e976..e45d6f205 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -1,8 +1,10 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; import type { Vector2, Vector3 } from '@kitware/vtk.js/types'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import type { Manifest, StateFile } from '@/src/io/state-file/schema'; import type { Maybe } from '@/src/types'; import { useImageStatsStore } from '@/src/store/image-stats'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; import { computed, ref, unref, watch } from 'vue'; import { watchImmediate } from '@vueuse/core'; import { vec3 } from 'gl-matrix'; @@ -10,32 +12,26 @@ import { defineStore } from 'pinia'; import { PaintMode } from '@/src/core/tools/paint'; import { computeEffectiveView } from '@/src/core/views/effectiveView'; import { worldPointToIndex } from '@/src/utils/imageSpace'; +import { maskScalars } from '@/src/segmentation/model'; +import { + clipExtent, + fullExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; import { Tools } from './types'; -import { useSegmentGroupStore } from '../segmentGroups'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useSegmentationStore } from '@/src/segmentation/store'; import useViewSliceStore from '../view-configs/slicing'; import { useViewStore } from '../views'; import { useViewCameraStore } from '../view-configs/camera'; import { useImageCacheStore } from '../image-cache'; -import { declareManifestRefs } from '@/src/core/manifestRefs'; -import { isRecord } from '@/src/utils'; - -// The manifest reference this store's sync orphan-watch keeps clean (see the -// activeSegmentGroupID watch below), declared for the dev-only save backstop. -declareManifestRefs('tools.paint', (manifest) => { - const tools = isRecord(manifest.tools) ? manifest.tools : {}; - const paint = isRecord(tools.paint) ? tools.paint : {}; - return typeof paint.activeSegmentGroupID === 'string' - ? [ - { - kind: 'segmentGroup' as const, - id: paint.activeSegmentGroupID, - where: 'tools.paint.activeSegmentGroupID', - }, - ] - : []; -}); const DEFAULT_BRUSH_SIZE = 4; + +// Growing a mask copies the whole of it, so a stroke that has to grow it asks +// for room beyond its footprint and the samples that follow grow nothing. +const STROKE_GROWTH_PADDING = 16; const DEFAULT_THRESHOLD_RANGE: Vector2 = [ Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, @@ -47,8 +43,6 @@ export const usePaintToolStore = defineStore('paint', () => { const activeMode = ref(PaintMode.CirclePaint); const modeBeforeProcess = ref(PaintMode.CirclePaint); const processControlsOpen = ref(false); - const activeSegmentGroupID = ref>(null); - const activeSegment = ref>(null); const brushSize = ref(DEFAULT_BRUSH_SIZE); const strokePoints = ref([]); const isActive = ref(false); @@ -56,7 +50,6 @@ export const usePaintToolStore = defineStore('paint', () => { const crossPlaneSync = ref(false); const paintPosition = ref([0, 0, 0]); const activePaintViewID = ref>(null); - const lastSegmentByGroup = ref>({}); const { currentImageID, currentImageMetadata } = useCurrentImage('global'); const imageStatsStore = useImageStatsStore(); @@ -68,28 +61,13 @@ export const usePaintToolStore = defineStore('paint', () => { return this.$paint.factory; } - const segmentGroupStore = useSegmentGroupStore(); - - // Delete-base cleanup: removing a dataset cascades away its segment groups. - // `serialize` writes the raw `activeSegmentGroupID`, so null it the instant - // its record leaves the store or the save manifest carries an orphaned id. - // Sync flush keeps this within the same `datasetStore.remove` call — the same - // remove-cascade contract as onImageDeleted, but keyed on segmentGroupID (not - // imageID), so it watches the record set instead of using that composable. - watch( - () => - activeSegmentGroupID.value != null && - !(activeSegmentGroupID.value in segmentGroupStore.metadataByID), - (orphaned) => { - if (orphaned) activeSegmentGroupID.value = null; - }, - { flush: 'sync' } - ); + const segmentationStore = useSegmentationStore(); const isPaintingModeActive = computed( () => activeMode.value === PaintMode.CirclePaint || - activeMode.value === PaintMode.Erase + activeMode.value === PaintMode.Erase || + activeMode.value === PaintMode.Eyedropper ); const activePaintMode = computed(() => isPaintingModeActive.value ? activeMode.value : modeBeforeProcess.value @@ -141,71 +119,28 @@ export const usePaintToolStore = defineStore('paint', () => { } /** - * Sets the active labelmap. - */ - function setActiveSegmentGroup(segmentGroupID: Maybe) { - activeSegmentGroupID.value = segmentGroupID; - } - - function getValidSegmentGroupID(imageID: Maybe): Maybe { - if (!imageID) return null; - - // If current segment group belongs to this image, keep using it - if ( - activeSegmentGroupID.value && - segmentGroupStore.metadataByID[activeSegmentGroupID.value] - ?.parentImage === imageID - ) { - return activeSegmentGroupID.value; - } - - // Otherwise look for other segment groups for this image - const segmentGroups = segmentGroupStore.orderByParent[imageID]; - if (segmentGroups && segmentGroups.length > 0) { - return segmentGroups[0]; - } - return null; - } - - /** - * Sets the active labelmap from a given image. - * - * If a labelmap exists, pick one. If no labelmap exists, create one. - */ - function ensureActiveSegmentGroupForImage(imageID: Maybe) { - if (!imageID) { - setActiveSegmentGroup(null); - return; - } - - const segmentGroupID = - getValidSegmentGroupID(imageID) ?? - segmentGroupStore.newLabelmapFromImage(imageID); - setActiveSegmentGroup(segmentGroupID); - } - - /** - * Sets the active segment. - * - * If the segment may be null | undefined, indicating no paint will occur. - * @param segValue + * The segment this operation writes into. It is allocated for a stroke that + * writes voxels; an erase takes what is already there, so it resolves nothing + * into existence and refuses when there is nothing stored to take from. */ - function setActiveSegment(this: _This, segValue: Maybe) { - if (segValue) { - if (!activeSegmentGroupID.value) - throw new Error('Cannot set active segment without a labelmap'); - - const { segments } = - segmentGroupStore.metadataByID[activeSegmentGroupID.value]; - - if (!(segValue in segments.byValue)) - throw new Error('Segment is not available for the active labelmap'); - - lastSegmentByGroup.value[activeSegmentGroupID.value] = segValue; - } - - activeSegment.value = segValue; - this.$paint.setBrushValue(segValue); + function resolveStrokeTarget(imageID: string, allocate: boolean) { + if (![PaintMode.CirclePaint, PaintMode.Erase].includes(activeMode.value)) + return undefined; + const maskId = allocate + ? segmentationStore.resolveEditTarget(imageID) + : segmentationStore.findEditTarget(imageID); + if (!maskId) return undefined; + + const binding = allocate + ? segmentationStore.ensureLabelmapBinding(maskId) + : segmentationStore.findMaskBinding(maskId); + if (!binding) return undefined; + + return { + maskId, + labelValue: SEGMENT_VALUE, + voxels: segmentationStore.maskVoxels(maskId), + }; } /** @@ -218,69 +153,120 @@ export const usePaintToolStore = defineStore('paint', () => { this.$paint.setBrushSize(size); } - function doPaintStroke(this: _This, axisIndex: 0 | 1 | 2, imageID: string) { - const segmentGroupID = getValidSegmentGroupID(imageID); - if (!segmentGroupID) return; + function selectSegmentAt(worldPoint: vec3, imageID: string) { + const registry = useSegmentStore().segments; + // Earlier registry entries render in front, including locked segments. + const segments = registry.segmentList.value; + const hit = segments.find((segment) => { + if (!registry.appearanceOf(segment.id).visible) return false; + const binding = segmentationStore.maskFor(imageID, segment.id) + ?.representations.labelmap; + if (!binding || isEmptyExtent(binding.extent)) return false; + const point = [...worldPointToIndex(binding.image, worldPoint)].map( + Math.round + ); + const dims = binding.image.getDimensions(); + if (point.some((value, axis) => value < 0 || value >= dims[axis])) + return false; + const [i, j, k] = point; + return ( + maskScalars(binding.image)[i + dims[0] * (j + dims[1] * k)] === + SEGMENT_VALUE + ); + }); + if (hit) registry.selectSegment(hit.id); + } - const labelmap = segmentGroupStore.dataIndex[segmentGroupID]; - if (!labelmap) return; + function doPaintStroke(this: _This, axisIndex: 0 | 1 | 2, imageID: string) { + // Asked before anything else: cancelling a preview and resolving the target + // (which mints the mask and its segmentation) are both side effects a + // refused stroke must not have. + if (segmentationStore.editTargetLocked()) return; + useSegmentationEditsStore().beforeEdit(); + const erasing = activeMode.value === PaintMode.Erase; + const target = resolveStrokeTarget(imageID, !erasing); + if (!target) return; + + const { voxels, labelValue, maskId } = target; + this.$paint.setBrushValue(labelValue); + + const parentImage = useImageCacheStore().getVtkImageData(imageID); + if (!parentImage) return; + const underlyingImagePixels = parentImage + .getPointData() + .getScalars() + .getData(); - // Prevent painting if active segment is locked or doesn't exist - if (activeSegment.value) { - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (!metadata) return; + const lastIndex = strokePoints.value.length - 1; + if (lastIndex < 0) return; + + // The stroke is stated in PARENT index space: a bounded mask's own origin + // moves as it grows, so its indices are not a fixed frame to state it in. + const lastIndexPoint = worldPointToIndex( + parentImage, + strokePoints.value[lastIndex] + ); + const prevIndexPoint = + lastIndex >= 1 + ? worldPointToIndex(parentImage, strokePoints.value[lastIndex - 1]) + : undefined; - const segment = metadata.segments.byValue[activeSegment.value]; - if (!segment || segment.locked) { - return; - } + const strokeExtent = clipExtent( + this.$paint.strokeBounds(axisIndex, lastIndexPoint, prevIndexPoint), + fullExtent(parentImage.getDimensions()) + ); + // Growth happens first, and nothing grows once the buffers below are read. + if (!erasing) { + voxels.ensureContains(strokeExtent, STROKE_GROWTH_PADDING); } - const imageData = useImageCacheStore().getVtkImageData(imageID); - const underlyingImagePixels = imageData - ?.getPointData() - .getScalars() - .getData(); + // Copied out of the reactive tree: the two closures below read it for + // every voxel the brush touches. + const extent = [...voxels.binding()!.extent] as Extent3D; + if (isEmptyExtent(extent)) return; + + // Resolved once per stroke: the claim below is made for every voxel the + // brush touches. A stroke is aimed at a place, so it takes the voxel. + const claimVoxel = segmentationStore.voxelClaim( + maskId, + 'aimed', + strokeExtent + ); + const parentDimensions = parentImage.getDimensions(); + const rowStride = parentDimensions[0]; + const sliceStride = parentDimensions[0] * parentDimensions[1]; + const maskData = voxels.scalars(); const [minThreshold, maxThreshold] = thresholdRange.value; - const shouldPaint = (idx: number) => { - if (!underlyingImagePixels) return false; - - // Prevent painting over locked segments - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (metadata) { - const currentData = labelmap - .getPointData() - .getScalars() - .getData() as Uint8Array; - const currentValue = currentData[idx]; - const segment = metadata.segments.byValue[currentValue]; - if (segment?.locked) { - return false; - } - } - const pixValue = underlyingImagePixels[idx]; + // The brush walks the PARENT grid and hands its points back in it, so the + // parent pixel under a voxel is a plain offset. Read a component at a + // time: both callbacks below run for every voxel the brush touches, and a + // triple per voxel is an allocation per voxel. + const parentOffset = (point: number[]) => + point[0] + point[1] * rowStride + point[2] * sliceStride; + + const shouldPaint = (offset: number, point: number[]) => { + // Erase clears the active segment only. + if (erasing && maskData[offset] !== labelValue) return false; + + const pixValue = underlyingImagePixels[parentOffset(point)]; return minThreshold <= pixValue && pixValue <= maxThreshold; }; - const lastIndex = strokePoints.value.length - 1; - if (lastIndex >= 0) { - const lastWorldPoint = strokePoints.value[lastIndex]; - const prevWorldPoint = - lastIndex >= 1 ? strokePoints.value[lastIndex - 1] : undefined; - - const lastIndexPoint = worldPointToIndex(labelmap, lastWorldPoint); - const prevIndexPoint = prevWorldPoint - ? worldPointToIndex(labelmap, prevWorldPoint) - : undefined; - - this.$paint.paintLabelmap( - labelmap, - axisIndex, - lastIndexPoint, - prevIndexPoint, - shouldPaint - ); + try { + this.$paint.paintLabelmap(voxels.image(), axisIndex, lastIndexPoint, { + endPoint: prevIndexPoint, + // Where this mask's buffer sits on the parent grid the points are in. + origin: [extent[0], extent[2], extent[4]], + shouldPaint, + onPainted: erasing + ? undefined + : (point: number[]) => { + claimVoxel?.claim(point[0], point[1], point[2]); + }, + }); + } finally { + claimVoxel?.finish(); } } @@ -294,42 +280,12 @@ export const usePaintToolStore = defineStore('paint', () => { this.$paint.setBrushScale(scale); } - function switchToSegmentGroupForImage(this: _This, imageID: string) { - const segmentGroupID = - getValidSegmentGroupID(imageID) ?? - segmentGroupStore.newLabelmapFromImage(imageID); - - if (!segmentGroupID) { - throw new Error( - `Failed to create or find segment group for image ${imageID}` - ); - } - - if (activeSegmentGroupID.value === segmentGroupID) return; - - setActiveSegmentGroup(segmentGroupID); - - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (!metadata) return; - - const lastSegment = lastSegmentByGroup.value[segmentGroupID]; - if (lastSegment !== undefined && lastSegment in metadata.segments.byValue) { - setActiveSegment.call(this, lastSegment); - return; - } - - if (metadata.segments.order.length > 0) { - setActiveSegment.call(this, metadata.segments.order[0]); - } - } - function startStroke( this: _This, worldPoint: vec3, axisIndex: 0 | 1 | 2, imageID: string ) { - switchToSegmentGroupForImage.call(this, imageID); strokePoints.value = [vec3.clone(worldPoint)]; doPaintStroke.call(this, axisIndex, imageID); } @@ -377,11 +333,12 @@ export const usePaintToolStore = defineStore('paint', () => { // --- setup and teardown --- // function activateTool(this: _This) { - const imageID = currentImageID.value; - if (!imageID) { + if (!currentImageID.value) { return false; } - ensureActiveSegmentGroupForImage(imageID); + // Selecting the tool configures the widget and nothing else. Storage is + // allocated by the first stroke, so picking up the brush and putting it + // down again leaves the image untouched. this.$paint.setBrushSize(this.brushSize); isActive.value = true; @@ -449,17 +406,13 @@ export const usePaintToolStore = defineStore('paint', () => { const paint = state.manifest.tools?.paint; if (!paint) return; - paint.activeSegmentGroupID = activeSegmentGroupID.value ?? null; paint.brushSize = brushSize.value; - paint.activeSegment = activeSegment.value; paint.crossPlaneSync = crossPlaneSync.value; } - function deserialize( - this: _This, - manifest: Manifest, - segmentGroupIDMap: Record - ) { + // The active segment rides on its segmentation, restored by the segmentation + // store before any tool deserializes. + function deserialize(this: _This, manifest: Manifest) { const paint = manifest.tools?.paint; if (!paint) return; @@ -467,13 +420,6 @@ export const usePaintToolStore = defineStore('paint', () => { setBrushSize.call(this, paint.brushSize); } isActive.value = manifest.tools?.current === Tools.Paint; - - if (paint.activeSegmentGroupID) { - activeSegmentGroupID.value = - segmentGroupIDMap[paint.activeSegmentGroupID]; - setActiveSegmentGroup(activeSegmentGroupID.value); - setActiveSegment.call(this, paint.activeSegment); - } setCrossPlaneSync(paint.crossPlaneSync ?? false); } @@ -481,8 +427,6 @@ export const usePaintToolStore = defineStore('paint', () => { activeMode, activePaintMode, processControlsOpen, - activeSegmentGroupID, - activeSegment, brushSize, strokePoints, isActive, @@ -499,13 +443,12 @@ export const usePaintToolStore = defineStore('paint', () => { setProcessControlsOpen, enterProcessMode, restoreModeAfterProcess, - setActiveSegmentGroup, - setActiveSegment, setBrushSize, setSliceAxis, setThresholdRange, setCrossPlaneSync, updatePaintPosition, + selectSegmentAt, startStroke, placeStrokePoint, endStroke, diff --git a/src/store/tools/paintProcess.ts b/src/store/tools/paintProcess.ts deleted file mode 100644 index 8220d4615..000000000 --- a/src/store/tools/paintProcess.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { defineStore, storeToRefs } from 'pinia'; -import { ref, computed, watch } from 'vue'; -import { TypedArray } from '@kitware/vtk.js/types'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { PaintMode } from '@/src/core/tools/paint'; -import { useMessageStore } from '@/src/store/messages'; -import { useCurrentImage } from '@/src/composables/useCurrentImage'; -import { useSegmentGroupStore } from '../segmentGroups'; - -export enum ProcessType { - FillHoles = 'fillHoles', - FillBetween = 'fillBetween', - GaussianSmooth = 'gaussianSmooth', -} - -type StartState = { - step: 'start'; -}; - -type ComputingState = { - step: 'computing'; - activeParentImageID: string | null; - activeSegmentGroupID: string; - processType: ProcessType; -}; - -type PreviewingState = { - step: 'previewing'; - activeParentImageID: string | null; - activeSegmentGroupID: string; - processType: ProcessType; - segImage: vtkLabelMap; - originalScalars: TypedArray | number[]; - processedScalars: TypedArray | number[]; - showingOriginal: boolean; -}; - -type ProcessState = StartState | ComputingState | PreviewingState; - -export type ProcessAlgorithm = ( - segImage: vtkLabelMap, - activeSegment: number -) => Promise; - -export const usePaintProcessStore = defineStore('paintProcess', () => { - const processState = ref({ step: 'start' }); - const activeProcessType = ref(ProcessType.FillHoles); - let activeProcessRunId = 0; - - const processStep = computed(() => processState.value.step); - - const showingOriginal = computed(() => { - const state = processState.value; - return state.step === 'previewing' ? state.showingOriginal : false; - }); - - function resetState() { - processState.value = { step: 'start' }; - } - - function confirmProcess() { - const state = processState.value; - // Apply commits the processed result. When the user is viewing the - // original, the image currently holds originalScalars, so restore the - // processed scalars before finishing or the result is silently discarded. - if (state.step === 'previewing' && state.showingOriginal) { - state.segImage - .getPointData() - .getScalars() - .setData(state.processedScalars); - state.segImage.modified(); - } - resetState(); - paintStore.restoreModeAfterProcess(); - } - - const segmentGroupStore = useSegmentGroupStore(); - const paintStore = usePaintToolStore(); - const { activeSegmentGroupID } = storeToRefs(paintStore); - const messageStore = useMessageStore(); - const { currentImageID } = useCurrentImage('global'); - - function rollbackPreview( - image: vtkLabelMap, - originalScalars: TypedArray | number[] - ): void { - image.getPointData().getScalars().setData(originalScalars); - image.modified(); - } - - function cancelProcess() { - const state = processState.value; - - if (state.step === 'previewing') { - rollbackPreview(state.segImage, state.originalScalars); - } - resetState(); - paintStore.restoreModeAfterProcess(); - } - - function setActiveProcessType(processType: ProcessType) { - // Cancel any active process before switching - cancelProcess(); - activeProcessType.value = processType; - } - - async function startProcess( - groupId: string, - algorithm: ProcessAlgorithm, - options?: { requiresActiveSegment?: boolean } - ) { - const activeSegment = paintStore.activeSegment; - // Most processes operate on the active segment; all-segments processes opt - // out so they are not blocked by (or limited to) a single active segment. - const requiresActiveSegment = options?.requiresActiveSegment ?? true; - - if (requiresActiveSegment) { - if (!activeSegment) { - messageStore.addError('No active segment selected'); - return; - } - - // Check if the active segment is locked - const segment = segmentGroupStore.getSegment(groupId, activeSegment); - if (segment?.locked) { - messageStore.addError('Cannot process locked segment'); - return; - } - } - - const segImage = segmentGroupStore.dataIndex[groupId]; - const activeParentImageID = - segmentGroupStore.metadataByID[groupId].parentImage; - const processType = activeProcessType.value; - const processRunId = ++activeProcessRunId; - - const originalScalars = segImage - .getPointData() - .getScalars() - .getData() - .slice(); - - paintStore.enterProcessMode(); - processState.value = { - step: 'computing', - activeParentImageID, - activeSegmentGroupID: groupId, - processType, - }; - - try { - const outputScalars = await algorithm(segImage, activeSegment ?? 0); - - // If the state changed during the async operation, stop processing. - if ( - processRunId !== activeProcessRunId || - processState.value.step !== 'computing' - ) { - return; - } - - const scalars = segImage.getPointData().getScalars(); - scalars.setData(outputScalars); - segImage.modified(); - - processState.value = { - step: 'previewing', - activeParentImageID, - activeSegmentGroupID: groupId, - processType, - segImage, - originalScalars, - processedScalars: outputScalars, - showingOriginal: false, - }; - } catch (error) { - if ( - processRunId !== activeProcessRunId || - processState.value.step !== 'computing' - ) { - return; - } - - messageStore.addError(`${processType} Operation Failed`, { - error: error as Error, - }); - rollbackPreview(segImage, originalScalars); - resetState(); - paintStore.restoreModeAfterProcess(); - } - } - - function togglePreview() { - const state = processState.value; - - if (state.step === 'previewing') { - const newShowingOriginal = !state.showingOriginal; - const scalarsToShow = newShowingOriginal - ? state.originalScalars - : state.processedScalars; - - state.segImage.getPointData().getScalars().setData(scalarsToShow); - state.segImage.modified(); - - processState.value = { - ...state, - showingOriginal: newShowingOriginal, - }; - } - } - - watch( - () => paintStore.activeMode, - (mode, previousMode) => { - if (previousMode !== PaintMode.Process || mode === PaintMode.Process) { - return; - } - const state = processState.value; - if (state.step !== 'computing' && state.step !== 'previewing') { - return; - } - cancelProcess(); - } - ); - - // Cancel process when active segment group changes - watch(activeSegmentGroupID, (groupId) => { - const state = processState.value; - if (state.step !== 'computing' && state.step !== 'previewing') { - return; - } - if (state.activeSegmentGroupID === groupId) { - return; - } - cancelProcess(); - }); - - // Cancel process when current image changes - watch(currentImageID, (newVal) => { - const state = processState.value; - if ( - (state.step === 'computing' || state.step === 'previewing') && - state.activeParentImageID !== newVal - ) { - cancelProcess(); - } - }); - - return { - processState, - processStep, - activeProcessType, - showingOriginal, - setActiveProcessType, - startProcess, - confirmProcess, - cancelProcess, - togglePreview, - resetState, - }; -}); diff --git a/src/store/tools/polygons.ts b/src/store/tools/polygons.ts index 410e3650b..e643c1cef 100644 --- a/src/store/tools/polygons.ts +++ b/src/store/tools/polygons.ts @@ -6,11 +6,11 @@ import { useToolSelectionStore, } from '@/src/store/tools/toolSelection'; import { AnnotationToolType } from '@/src/store/tools/types'; -import { POLYGON_LABEL_DEFAULTS } from '@/src/config'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; import { getPlaneTransforms } from '@/src/utils/frameOfReference'; import { ToolID } from '@/src/types/annotation-tool'; import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -38,7 +38,8 @@ const ensureVec2 = (regions: (Vec2 | Vec6)[][]) => { export const usePolygonStore = defineAnnotationToolStore('polygon', () => { const toolAPI = useAnnotationTool({ toolDefaults, - initialLabels: POLYGON_LABEL_DEFAULTS, + segments: () => useSegmentStore().segments, + manifestKey: 'polygons', }); function getPoints(id: ToolID) { @@ -129,14 +130,14 @@ export const usePolygonStore = defineAnnotationToolStore('polygon', () => { return mergedTool; }; - const sameSliceAndLabel = (a: Tool, b: Tool) => - a.label === b.label && + const sameSliceAndSegment = (a: Tool, b: Tool) => + a.segmentId === b.segmentId && a.slice === b.slice && a.frame === b.frame && a.frameOfReference === b.frameOfReference; const mergable = (a: Tool, b: Tool) => { - if (!sameSliceAndLabel(a, b)) return false; + if (!sameSliceAndSegment(a, b)) return false; return polygonsOverlap(a, b); }; // --- // @@ -194,8 +195,12 @@ export const usePolygonStore = defineAnnotationToolStore('polygon', () => { state.manifest.tools.polygons = toolAPI.serializeTools(); } - function deserialize(manifest: Manifest, dataIDMap: Record) { - toolAPI.deserializeTools(manifest.tools?.polygons, dataIDMap); + function deserialize( + manifest: Manifest, + dataIDMap: Record, + segmentIdMap: Record = {} + ) { + toolAPI.deserializeTools(manifest.tools?.polygons, dataIDMap, segmentIdMap); } return { diff --git a/src/store/tools/rectangles.ts b/src/store/tools/rectangles.ts index 24d028e68..23207e280 100644 --- a/src/store/tools/rectangles.ts +++ b/src/store/tools/rectangles.ts @@ -1,9 +1,9 @@ import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore'; import type { Vector3 } from '@kitware/vtk.js/types'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; -import { RECTANGLE_LABEL_DEFAULTS } from '@/src/config'; import { ToolID } from '@/src/types/annotation-tool'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -19,15 +19,11 @@ const rectangleDefaults = () => ({ fillColor: 'transparent', }); -const newLabelDefault = { - fillColor: 'transparent', -}; - export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { const toolAPI = useAnnotationTool({ toolDefaults: rectangleDefaults, - initialLabels: RECTANGLE_LABEL_DEFAULTS, - newLabelDefault, + segments: () => useSegmentStore().segments, + manifestKey: 'rectangles', }); function getPoints(id: ToolID) { @@ -42,8 +38,16 @@ export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { state.manifest.tools.rectangles = toolAPI.serializeTools(); } - function deserialize(manifest: Manifest, dataIDMap: Record) { - toolAPI.deserializeTools(manifest.tools?.rectangles, dataIDMap); + function deserialize( + manifest: Manifest, + dataIDMap: Record, + segmentIdMap: Record = {} + ) { + toolAPI.deserializeTools( + manifest.tools?.rectangles, + dataIDMap, + segmentIdMap + ); } return { diff --git a/src/store/tools/rulers.ts b/src/store/tools/rulers.ts index cf02ca1a6..f4c01a1c6 100644 --- a/src/store/tools/rulers.ts +++ b/src/store/tools/rulers.ts @@ -4,9 +4,9 @@ import type { Vector3 } from '@kitware/vtk.js/types'; import { distance2BetweenPoints } from '@kitware/vtk.js/Common/Core/Math'; import { ToolID } from '@/src/types/annotation-tool'; -import { RULER_LABEL_DEFAULTS } from '@/src/config'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -24,7 +24,8 @@ const rulerDefaults = () => ({ export const useRulerStore = defineAnnotationToolStore('ruler', () => { const annotationTool = useAnnotationTool({ toolDefaults: rulerDefaults, - initialLabels: RULER_LABEL_DEFAULTS, + segments: () => useSegmentStore().segments, + manifestKey: 'rulers', }); // prefix some props with ruler @@ -62,12 +63,16 @@ export const useRulerStore = defineAnnotationToolStore('ruler', () => { state.manifest.tools.rulers = serializeTools(); } - function deserialize(manifest: Manifest, dataIDMap: Record) { - deserializeTools(manifest.tools?.rulers, dataIDMap); + function deserialize( + manifest: Manifest, + dataIDMap: Record, + segmentIdMap: Record = {} + ) { + deserializeTools(manifest.tools?.rulers, dataIDMap, segmentIdMap); } return { - ...annotationTool, // support useAnnotationTool interface (for MeasurementsToolList) + ...annotationTool, rulerIDs, rulerByID, rulers, diff --git a/src/store/tools/types.ts b/src/store/tools/types.ts index 6919c118c..04f580e67 100644 --- a/src/store/tools/types.ts +++ b/src/store/tools/types.ts @@ -27,7 +27,13 @@ export interface IActivatableTool { export interface ISerializableTool { serialize: (state: StateFile) => void; - deserialize: (manifest: Manifest, dataIDMap: Record) => void; + deserialize: ( + manifest: Manifest, + dataIDMap: Record, + // Save-time type id -> restored type id, for the tools that share the + // delineation registry. + segmentIdMap?: Record + ) => void; } export interface IToolStore diff --git a/src/store/tools/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index d9a348913..fc85ea338 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -1,10 +1,6 @@ -import { Ref, computed, ref, watch } from 'vue'; +import { Ref, computed, markRaw, ref } from 'vue'; import type { Vector3 } from '@kitware/vtk.js/types'; import type { Maybe, PartialWithRequired, UnwrapAll } from '@/src/types'; -import { - STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, - TOOL_COLORS, -} from '@/src/config'; import { isRecord, removeFromArray } from '@/src/utils'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { onImageDeleted } from '@/src/composables/onImageDeleted'; @@ -14,35 +10,44 @@ import { useIdStore } from '@/src/store/id'; import { useToolSelectionStore } from '@/src/store/tools/toolSelection'; import type { IToolStore } from '@/src/store/tools/types'; import { applyLocator } from '@/src/core/annotations/locator'; -import { useLabels, type Labels } from './useLabels'; +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { declareSegmentReferences } from '@/src/segmentation/segmentReferences'; // Shared manifest-ref declaration for the annotation-tool stores. Each store // calls this at module scope next to its serialize, pairing the dev-backstop // coverage with the onImageDeleted cascade this composable registers. -export const declareAnnotationToolManifestRefs = ( - key: 'rulers' | 'rectangles' | 'polygons' -) => +export type AnnotationToolKey = 'rulers' | 'rectangles' | 'polygons'; + +export const declareAnnotationToolManifestRefs = (key: AnnotationToolKey) => declareManifestRefs(`tools.${key}`, (manifest) => { const tools = isRecord(manifest.tools) ? manifest.tools : {}; const section = tools[key]; if (!isRecord(section) || !Array.isArray(section.tools)) return []; - return section.tools.flatMap((entry, index) => - isRecord(entry) && typeof entry.imageID === 'string' - ? [ - { - kind: 'dataset' as const, - id: entry.imageID, - where: `tools.${key}[${index}].imageID`, - }, - ] - : [] - ); + return section.tools.flatMap((entry, index) => { + if (!isRecord(entry)) return []; + return [ + ...(typeof entry.imageID === 'string' + ? [ + { + kind: 'dataset' as const, + id: entry.imageID, + where: `tools.${key}[${index}].imageID`, + }, + ] + : []), + ...(typeof entry.segmentId === 'string' && entry.segmentId + ? [ + { + kind: 'segment' as const, + id: entry.segmentId, + where: `tools.${key}[${index}].segmentId`, + }, + ] + : []), + ]; + }); }); -const annotationToolLabelDefault = Object.freeze({ - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT as number, -}); - const makeAnnotationToolDefaults = () => ({ frameOfReference: { planeOrigin: [0, 0, 0], @@ -51,23 +56,23 @@ const makeAnnotationToolDefaults = () => ({ slice: -1, imageID: '', placing: false, - color: TOOL_COLORS[0], - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + segmentId: '', name: 'baseAnnotationTool', }); // Must return addTool in consuming Pinia store. export const useAnnotationTool = < MakeToolDefaults extends (...args: any) => any, - LabelProps, >({ toolDefaults, - initialLabels, - newLabelDefault, + segments, + manifestKey, }: { toolDefaults: MakeToolDefaults; - initialLabels: Labels; - newLabelDefault?: LabelProps; + // Factory, not the invoked registry: tools are created inside store setup. + segments: () => SegmentRegistry; + // The manifest section this tool owns, which is also its reference-holder id. + manifestKey: AnnotationToolKey; }) => { type ToolDefaults = ReturnType; type Tool = ToolDefaults & AnnotationTool; @@ -88,21 +93,7 @@ export const useAnnotationTool = < tools.value.filter((tool): tool is FinishedTool => !tool.placing) ); - const labels = useLabels({ - ...annotationToolLabelDefault, - ...newLabelDefault, - }); - labels.mergeLabels(initialLabels); - - function makePropsFromLabel(label: string | undefined) { - if (!label) return { labelName: '' }; - - const labelProps = labels.labels.value[label]; - if (labelProps) return labelProps; - - // if label deleted, remove label name from tool - return { labelName: '' }; - } + const registry = segments(); function addTool(tool: ToolPatch): ToolID { const id = useIdStore().nextId() as ToolID; @@ -113,10 +104,8 @@ export const useAnnotationTool = < toolByID.value[id] = { ...makeAnnotationToolDefaults(), ...toolDefaults(), - label: labels.activeLabel.value, + segmentId: registry.selectedSegmentId.value ?? '', ...tool, - // updates label props if changed between sessions - ...makePropsFromLabel(tool.label), id, }; @@ -124,6 +113,9 @@ export const useAnnotationTool = < return id; } + const appearanceOfTool = (id: ToolID) => + registry.appearanceOf(toolByID.value[id]?.segmentId); + function removeTool(id: ToolID) { if (!(id in toolByID.value)) return; @@ -140,10 +132,29 @@ export const useAnnotationTool = < toolByID.value[id] = { ...toolByID.value[id], ...patch, id }; } + // Starting an annotation is the gesture that names the segment it delineates: + // one begun against nothing mints and selects a segment the way a first paint + // stroke does, so it is drawn in that segment's color while it is still being + // placed. Idempotent, since the tool then names a live segment. + function resolveToolType(id: ToolID) { + const tool = toolByID.value[id]; + if (!tool || registry.getSegment(tool.segmentId)) return; + updateTool(id, { + segmentId: registry.ensureSelectedSegment(), + } as ToolPatch); + } + + // Placing resolves too, for an annotation that arrived without one of the + // gestures that would have. + function placeTool(id: ToolID) { + resolveToolType(id); + updateTool(id, { placing: false } as ToolPatch); + } + // Delete-base cleanup: a removed image's tools // must not linger — they are invisible in the UI (tool lists filter to the // current image) and an orphaned imageID in the next save manifest is the - // backend's intentional fail-closed 400. Mirrors the segment-group cascade. + // backend's intentional fail-closed 400. Mirrors the segmentation cascade. onImageDeleted((deletedIDs) => { const deleted = new Set(deletedIDs); toolIDs.value @@ -151,15 +162,6 @@ export const useAnnotationTool = < .forEach((id) => removeTool(id)); }); - // updates props controlled by labels - watch(labels.labels, () => { - toolIDs.value.forEach((id) => { - const tool = toolByID.value[id]; - const propsFromLabel = makePropsFromLabel(tool.label); - updateTool(id, { ...tool, ...propsFromLabel }); - }); - }); - const { currentImageID } = useCurrentImage('global'); function jumpToTool(toolID: ToolID) { @@ -180,44 +182,66 @@ export const useAnnotationTool = < ...rest, })); - return { - tools: toolsSerialized, - labels: labels.labels.value, - }; + return { tools: toolsSerialized }; }; type Serialized = { tools: PartialWithRequired[]; - labels: Labels; }; + // An unmapped segment leaves its shape unnamed. An adopted segment deleted + // during mask IO instead takes its pending shapes with it, just as it takes + // already attached shapes; a same-name replacement has a different id. function deserializeTools( serialized: Maybe, - dataIDMap: Record + dataIDMap: Record, + segmentIdMap: Record = {} ) { - if (serialized?.labels) { - labels.clearDefaultLabels(); - } - const labelIDMap = Object.fromEntries( - Object.entries(serialized?.labels ?? {}).map(([id, label]) => { - const newID = labels.addLabel(label); // side effect in Array.map - return [id, newID]; - }) - ); - serialized?.tools + .filter(({ segmentId }) => { + const mappedId = segmentId && segmentIdMap[segmentId]; + return !mappedId || registry.getSegment(mappedId); + }) + // An image that did not load leaves its annotations with nothing to hang + // on: they cannot be drawn, and seating them with a missing image would + // make the next save's whole tools section invalid. + .filter(({ imageID }) => dataIDMap[imageID] !== undefined) .map( - ({ imageID, label, ...rest }) => + ({ imageID, segmentId, ...rest }) => ({ ...rest, imageID: dataIDMap[imageID], - label: (label && labelIDMap[label]) || '', + segmentId: (segmentId && segmentIdMap[segmentId]) || '', }) as ToolPatch ) .forEach((tool) => addTool(tool)); } + // A tool still being placed is the widget's own stub, not content: taking it + // with a deleted segment would leave the widget holding a dead id and no way + // to place anything, and placing re-resolves the segment anyway. + const referencesSegment = (id: ToolID, segmentId: string) => { + const tool = toolByID.value[id]; + return tool.segmentId === segmentId && !tool.placing; + }; + + const removeToolsOfSegment = (segmentId: string) => + toolIDs.value + .filter((id) => referencesSegment(id, segmentId)) + .forEach((id) => removeTool(id)); + + const hasToolsOfSegment = (segmentId: string) => + toolIDs.value.some((id) => referencesSegment(id, segmentId)); + + declareSegmentReferences(manifestKey, { + has: hasToolsOfSegment, + remove: removeToolsOfSegment, + }); + return { - ...labels, + segments: markRaw(registry), + appearanceOfTool, + removeToolsOfSegment, + hasToolsOfSegment, toolIDs, toolByID, tools, @@ -225,6 +249,8 @@ export const useAnnotationTool = < addTool, removeTool, updateTool, + resolveToolType, + placeTool, jumpToTool, serializeTools, deserializeTools, @@ -234,7 +260,7 @@ export const useAnnotationTool = < type ToolFactory = (...args: any[]) => T; export type AnnotationToolAPI = ReturnType< - typeof useAnnotationTool, any> + typeof useAnnotationTool> > & { getPoints(id: ToolID): Vector3[]; }; diff --git a/src/store/tools/useLabels.ts b/src/store/tools/useLabels.ts deleted file mode 100644 index 4731d4074..000000000 --- a/src/store/tools/useLabels.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Maybe, UnwrapAll } from '@/src/types'; -import { ref } from 'vue'; -import { TOOL_COLORS } from '@/src/config'; -import { useIdStore } from '../id'; - -const labelDefault = Object.freeze({ - labelName: 'New Label' as string, - color: TOOL_COLORS[0] as string, -}); - -export type Label = Partial; -export type Labels = Record>; - -type LabelID = string; - -// param newLabelDefault should contain all label controlled props -// of the tool so placing tool does hold any last active label props. -export const useLabels = (newLabelDefault: Props) => { - type ToolLabel = Label; - type ToolLabels = Labels; - - const labels = ref({}); - - const activeLabel = ref(); - // Accepts undefined so a caller that must not disturb the picker — applying a - // job's annotations result — can put back an activeLabel that was never set. - const setActiveLabel = (id: string | undefined) => { - activeLabel.value = id; - }; - - let nextToolColorIndex = 0; - - const addLabel = (label: ToolLabel = {}) => { - const id = useIdStore().nextId(); - labels.value[id] = { - ...labelDefault, - ...newLabelDefault, - color: TOOL_COLORS[nextToolColorIndex], - ...label, - }; - - nextToolColorIndex = (nextToolColorIndex + 1) % TOOL_COLORS.length; - - setActiveLabel(id); - return id; - }; - - const deleteLabel = (id: LabelID) => { - if (!(id in labels.value)) throw new Error('Label does not exist'); - - delete labels.value[id]; - labels.value = { ...labels.value }; // trigger reactive update for measurement list - - // pick another active label if deleted was active - if (id === activeLabel.value) { - const labelIDs = Object.keys(labels.value); - if (labelIDs.length !== 0) setActiveLabel(labelIDs[0]); - else setActiveLabel(''); - } - }; - - const updateLabel = (id: LabelID, patch: ToolLabel) => { - if (!(id in labels.value)) throw new Error('Label does not exist'); - - labels.value = { ...labels.value, [id]: { ...labels.value[id], ...patch } }; - }; - - // Flag to indicate if should clear existing labels - const defaultLabels = ref(true); - - const clearDefaultLabels = () => { - if (defaultLabels.value) labels.value = {}; - defaultLabels.value = false; - }; - - const findLabel = (name: Maybe) => { - return Object.entries(labels.value).find( - ([, { labelName }]) => name === labelName - ); - }; - - /* - * If input label has the same name as existing label, update existing label with input label properties. - * - * param label: label to merge - * param clearDefault: if true, clear initial labels, do nothing if initial labels already cleared - */ - const mergeLabel = (label: ToolLabel) => { - const { labelName } = label; - const matchingName = findLabel(labelName); - - if (matchingName) { - const [existingID] = matchingName; - updateLabel(existingID, label); - return existingID; - } - - return addLabel(label); - }; - - /* - * If input label has the same name as existing label, update existing label with input label properties. - * - * param newLabels: each key is the label name - * param clearDefault: if true, clear initial labels, do nothing if initial labels already cleared - */ - const mergeLabels = (newLabels: Maybe) => { - Object.entries(newLabels ?? {}).forEach(([labelName, props]) => - mergeLabel({ ...props, labelName }) - ); - }; - - return { - labels, - activeLabel, - setActiveLabel, - addLabel, - deleteLabel, - updateLabel, - // Exposed for callers that need the merged label's id back — applying a - // job's annotations result maps wire label NAMES to store label ids. - mergeLabel, - mergeLabels, - findLabel, - clearDefaultLabels, - }; -}; - -export type LabelsStore = UnwrapAll>>; diff --git a/src/store/view-configs/layers.ts b/src/store/view-configs/layers.ts index b01f77a9b..11ea9abf1 100644 --- a/src/store/view-configs/layers.ts +++ b/src/store/view-configs/layers.ts @@ -27,7 +27,7 @@ function getPreset(id: string) { const layersStore = useLayersStore(); const layer = layersStore.getLayer(id); if (!layer) { - // Return default preset if layer not found (e.g., for segment groups) + // Return default preset if layer not found (e.g., for segmentations) return LAYER_PRESET_DEFAULT; } diff --git a/src/store/view-configs/segmentGroups.ts b/src/store/view-configs/segmentGroups.ts deleted file mode 100644 index 7bbf320b2..000000000 --- a/src/store/view-configs/segmentGroups.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { reactive, computed, unref, MaybeRef } from 'vue'; -import { defineStore } from 'pinia'; - -import { - DoubleKeyRecord, - deleteSecondKey, - getDoubleKeyRecord, - patchDoubleKeyRecord, -} from '@/src/utils/doubleKeyRecord'; -import { Maybe } from '@/src/types'; - -import { createViewConfigSerializer } from '@/src/store/view-configs/common'; -import { ViewConfig } from '@/src/io/state-file/schema'; -import { SegmentGroupConfig } from '@/src/store/view-configs/types'; -import { useViewStore } from '@/src/store/views'; - -type Config = SegmentGroupConfig; -const CONFIG_NAME = 'segmentGroup'; - -export const defaultConfig = () => ({ - outlineOpacity: 1.0, - outlineThickness: 2, -}); - -export const useSegmentGroupConfigStore = defineStore( - `${CONFIG_NAME}Config`, - () => { - const configs = reactive>({}); - - const getConfig = (viewID: Maybe, dataID: Maybe) => - getDoubleKeyRecord(configs, viewID, dataID) ?? defaultConfig(); - - const updateConfig = ( - viewID: string, - dataID: string, - patch: Partial - ) => { - const config = { - ...defaultConfig(), - ...getConfig(viewID, dataID), - ...patch, - }; - - patchDoubleKeyRecord(configs, viewID, dataID, config); - }; - - const removeView = (viewID: string) => { - delete configs[viewID]; - }; - - const removeData = (dataID: string, viewID?: string) => { - if (viewID) { - delete configs[viewID]?.[dataID]; - } else { - deleteSecondKey(configs, dataID); - } - }; - - const serialize = createViewConfigSerializer(configs, CONFIG_NAME); - - const deserialize = ( - viewID: string, - config: Record - ) => { - Object.entries(config).forEach(([dataID, viewConfig]) => { - if (viewConfig.segmentGroup) { - updateConfig(viewID, dataID, viewConfig.segmentGroup); - } - }); - }; - - // For updating all configs together // - - const aConfig = computed(() => { - const viewIDs = Object.keys(configs); - if (viewIDs.length === 0) return null; - const firstViewID = viewIDs[0]; - const dataIDs = Object.keys(configs[firstViewID]); - if (dataIDs.length === 0) return null; - const firstDataID = dataIDs[0]; - return configs[firstViewID][firstDataID]; - }); - - const updateAllConfigs = (dataID: string, patch: Partial) => { - Object.keys(configs).forEach((viewID) => { - updateConfig(viewID, dataID, patch); - }); - }; - - return { - configs, - getConfig, - updateConfig, - removeView, - removeData, - serialize, - deserialize, - aConfig, - updateAllConfigs, - }; - } -); - -export const useGlobalSegmentGroupConfig = (dataId: MaybeRef) => { - const store = useSegmentGroupConfigStore(); - const viewStore = useViewStore(); - - const views = computed(() => - viewStore.getAllViews().filter((view) => view.type === '2D') - ); - - const configs = computed(() => - views.value.map((view) => ({ - config: store.getConfig(view.id, unref(dataId)), - viewID: view.id, - })) - ); - - // get any one - const config = computed(() => configs.value.find(({ config: c }) => c)); - - // update all configs - const updateConfig = (patch: Partial) => { - configs.value.forEach(({ viewID }) => - store.updateConfig(viewID, unref(dataId), patch) - ); - }; - - return { config, updateConfig }; -}; diff --git a/src/store/view-configs/types.ts b/src/store/view-configs/types.ts index f81d5498e..6503e2fbd 100644 --- a/src/store/view-configs/types.ts +++ b/src/store/view-configs/types.ts @@ -51,11 +51,6 @@ export interface LayersConfig { blendConfig: BlendConfig; } -export interface SegmentGroupConfig { - outlineOpacity: number; - outlineThickness: number; -} - export interface CinePlaybackViewConfig { frame: number; } diff --git a/src/types/annotation-tool.ts b/src/types/annotation-tool.ts index e487965bd..ffce37720 100644 --- a/src/types/annotation-tool.ts +++ b/src/types/annotation-tool.ts @@ -23,11 +23,7 @@ export type AnnotationTool = { */ placing?: boolean; - label?: string; - labelName?: string; - - color: string; - strokeWidth?: number; + segmentId?: string; name: string; diff --git a/src/types/segment.ts b/src/types/segment.ts deleted file mode 100644 index f530763af..000000000 --- a/src/types/segment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RGBAColor } from '@kitware/vtk.js/types'; - -export interface SegmentMask { - value: number; - name: string; - color: RGBAColor; - visible: boolean; - locked?: boolean; -} diff --git a/src/utils/__tests__/allocateImageFromChunks.spec.ts b/src/utils/__tests__/allocateImageFromChunks.spec.ts index a47bb2be8..a9d4878db 100644 --- a/src/utils/__tests__/allocateImageFromChunks.spec.ts +++ b/src/utils/__tests__/allocateImageFromChunks.spec.ts @@ -236,7 +236,7 @@ describe('getTypedArrayValueRange', () => { }); }); - it('has no range to report for element types the allocator never makes', () => { + it('has no range to report for element segments the allocator never makes', () => { expect(getTypedArrayValueRange(Float32Array)).toBeUndefined(); }); }); diff --git a/src/utils/bugReport.ts b/src/utils/bugReport.ts index 59181ba62..76c47df27 100644 --- a/src/utils/bugReport.ts +++ b/src/utils/bugReport.ts @@ -3,7 +3,7 @@ import { useDatasetStore } from '@/src/store/datasets'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useImageCacheStore } from '@/src/store/image-cache'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; import { COMPOUND_EXTENSIONS } from '@/src/utils/path'; const MAX_ERROR_LENGTH = 4000; @@ -35,7 +35,7 @@ const collectDatasetInfo = (): string[] => { const datasetStore = useDatasetStore(); const imageCacheStore = useImageCacheStore(); const dicomStore = useDICOMStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); return datasetStore.idsAsSelections.map((id, i) => { const metadata = imageCacheStore.getImageMetadata(id); @@ -57,10 +57,11 @@ const collectDatasetInfo = (): string[] => { ? 'DICOM' : 'unknown'; - const segCount = segmentGroupStore.orderByParent[id]?.length ?? 0; + const segCount = + segmentationStore.getSegmentationForImage(id)?.order.length ?? 0; const segPart = segCount > 0 - ? ` (segment groups: ${segCount} as ${segmentGroupStore.saveFormat})` + ? ` (segments: ${segCount} as ${segmentationStore.saveFormat})` : ''; return ` [${i}] ${dims} ${dataType} from ${sourceFormat}${segPart}`; @@ -81,11 +82,11 @@ export const generateBugReport = (error?: Error): string => { ]; const datasets = collectDatasetInfo(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); lines.push('', `Datasets: ${datasets.length}`); lines.push(...datasets); - lines.push(`Save format: ${segmentGroupStore.saveFormat}`); + lines.push(`Save format: ${segmentationStore.saveFormat}`); lines.push('--- End Report ---'); diff --git a/src/utils/dataSelection.ts b/src/utils/dataSelection.ts index ce950a633..c0eeabd1a 100644 --- a/src/utils/dataSelection.ts +++ b/src/utils/dataSelection.ts @@ -1,6 +1,7 @@ import { getDisplayName, useDICOMStore } from '@/src/store/datasets-dicom'; import { useImageCacheStore } from '@/src/store/image-cache'; import { Maybe } from '@/src/types'; +import { stripExtension } from '@/src/utils/path'; export type DataSelection = string; @@ -31,3 +32,13 @@ export const getSelectionName = (selection: string) => { } return getImageName(selection); }; + +/** + * A DICOM display name is not a filename, so only a file-backed selection is + * stripped. + */ +export const getSelectionStem = (selection: string) => { + const name = getSelectionName(selection); + if (!name) return undefined; + return isRegularImage(selection) ? stripExtension(name) : name; +}; diff --git a/src/vtk/LabelMap/index.d.ts b/src/vtk/LabelMap/index.d.ts index df8c3c378..1716092da 100644 --- a/src/vtk/LabelMap/index.d.ts +++ b/src/vtk/LabelMap/index.d.ts @@ -1,25 +1,11 @@ -import { SegmentMask } from '@/src/types/segment'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import type { Vector4 } from '@kitware/vtk.js/types'; +/** + * SegmentMask voxel storage. Its own class so a mask stays distinguishable from + * the image it sits on, in the type system and in serialized state alike. + */ export interface vtkLabelMap extends vtkImageData { - /** - * Sets the segments of the labelmap. - * @param segments - */ - setSegments(segments: SegmentMask[]): boolean; - - /** - * Gets the segments of the labelmap. - */ - getSegments(): SegmentMask[]; - - /** - * Replaces a labelmap value with another value. - * @param from - * @param to - */ - replaceLabelValue(from: number, to: number): void; + getClassName(): 'vtkLabelMap'; } export function newInstance(initialValues?: any): vtkLabelMap; diff --git a/src/vtk/LabelMap/index.js b/src/vtk/LabelMap/index.js index 0c8acf6af..1dad3cc25 100644 --- a/src/vtk/LabelMap/index.js +++ b/src/vtk/LabelMap/index.js @@ -1,54 +1,13 @@ import macro from '@kitware/vtk.js/macro'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import deepEqual from 'deep-equal'; - -// ---------------------------------------------------------------------------- -// vtkLabelMap methods -// ---------------------------------------------------------------------------- - -function vtkLabelMap(publicAPI, model) { - // Set our className - model.classHierarchy.push('vtkLabelMap'); - - const originalAPI = { ...publicAPI }; - - publicAPI.replaceLabelValue = (from, to) => { - const pixels = publicAPI.getPointData().getScalars().getData(); - const len = pixels.length; - for (let i = 0; i < len; i++) { - if (pixels[i] === from) { - pixels[i] = to; - } - } - }; - - publicAPI.setSegments = (segments) => { - if (segments === model.segments || deepEqual(segments, model.segments)) { - return false; - } - return originalAPI.setSegments(segments); - }; -} // ---------------------------------------------------------------------------- // Object factory // ---------------------------------------------------------------------------- -const defaultValues = () => ({ - segments: [], -}); - -// ---------------------------------------------------------------------------- - export function extend(publicAPI, model, initialValues = {}) { - Object.assign(model, defaultValues(), initialValues); - vtkImageData.extend(publicAPI, model, initialValues); - - macro.setGet(publicAPI, model, ['segments']); - - // Object specific methods - vtkLabelMap(publicAPI, model); + model.classHierarchy.push('vtkLabelMap'); } // ---------------------------------------------------------------------------- diff --git a/src/vtk/PaintWidget/behavior.ts b/src/vtk/PaintWidget/behavior.ts index 6514aca03..77d780cf7 100644 --- a/src/vtk/PaintWidget/behavior.ts +++ b/src/vtk/PaintWidget/behavior.ts @@ -11,15 +11,26 @@ export default function widgetBehavior(publicAPI: any, model: any) { const getWorldCoords = computeWorldCoords(model); // support setting per-view widget manipulators - macro.setGet(publicAPI, model, ['manipulator']); + macro.setGet(publicAPI, model, ['manipulator', 'sampling']); let isPainting = false; + let samplingStroke = false; + + const setSampling = publicAPI.setSampling; + publicAPI.setSampling = (sampling: boolean) => { + // Once a gesture samples, it cannot resume writing before a fresh press. + if (sampling && isPainting) samplingStroke = true; + return setSampling(sampling); + }; /** * Starts painting */ publicAPI.handleLeftButtonPress = (eventData: any) => { - if (!model.manipulator || shouldIgnoreEvent(eventData)) { + if ( + !model.manipulator || + (!model.sampling && shouldIgnoreEvent(eventData)) + ) { return macro.VOID; } @@ -32,7 +43,8 @@ export default function widgetBehavior(publicAPI: any, model: any) { brush.setOrigin(...worldCoords); isPainting = true; - publicAPI.invokeStartInteractionEvent(); + samplingStroke = !!model.sampling; + publicAPI.invokeStartInteractionEvent({ sampling: samplingStroke }); return macro.EVENT_ABORT; }; @@ -40,7 +52,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { * Paints */ publicAPI.handleMouseMove = (eventData: any) => { - if (shouldIgnoreEvent(eventData)) { + if (isPainting && !model.sampling && shouldIgnoreEvent(eventData)) { return macro.VOID; } @@ -54,7 +66,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { brush.setOrigin(...worldCoords); if (isPainting) { - publicAPI.invokeInteractionEvent(); + if (!samplingStroke) publicAPI.invokeInteractionEvent(); return macro.EVENT_ABORT; } @@ -65,13 +77,13 @@ export default function widgetBehavior(publicAPI: any, model: any) { /** * Finishes paint */ - publicAPI.handleLeftButtonRelease = (eventData: any) => { - if (!isPainting || shouldIgnoreEvent(eventData)) { + publicAPI.handleLeftButtonRelease = () => { + if (!isPainting) { return macro.VOID; } isPainting = false; - publicAPI.invokeEndInteractionEvent(); + if (!samplingStroke) publicAPI.invokeEndInteractionEvent(); return macro.EVENT_ABORT; }; @@ -81,20 +93,4 @@ export default function widgetBehavior(publicAPI: any, model: any) { } return macro.VOID; }; - - publicAPI.grabFocus = () => { - if (!model.hasFocus) { - model.hasFocus = true; - model._interactor.requestAnimation(publicAPI); - } - }; - - publicAPI.loseFocus = () => { - if (model.hasFocus) { - model._interactor.cancelAnimation(publicAPI); - } - model.hasFocus = false; - // model._widgetManager.enablePicking(); - // model._interactor.render(); - }; } diff --git a/src/vtk/PaintWidget/index.d.ts b/src/vtk/PaintWidget/index.d.ts index a748ed822..4e8946021 100644 --- a/src/vtk/PaintWidget/index.d.ts +++ b/src/vtk/PaintWidget/index.d.ts @@ -5,6 +5,7 @@ import { mat4, vec3 } from 'gl-matrix'; import { PaintWidgetState } from './state'; export interface vtkPaintViewWidget extends vtkAbstractWidget { + setSampling(sampling: boolean): boolean; setManipulator(manipulator: vtkPlaneManipulator): boolean; getManipulator(): vtkPlaneManipulator; setSlicingIndex(index: number): boolean; diff --git a/src/vtk/RulerWidget/__tests__/behavior.spec.ts b/src/vtk/RulerWidget/__tests__/behavior.spec.ts new file mode 100644 index 000000000..25c3b02f5 --- /dev/null +++ b/src/vtk/RulerWidget/__tests__/behavior.spec.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import macro from '@kitware/vtk.js/macros'; +import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; +import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget'; +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkSelectionNode from '@kitware/vtk.js/Common/DataModel/SelectionNode'; +import vtkRulerWidget from '@/src/vtk/RulerWidget'; +import vtkRectangleWidget from '@/src/vtk/RectangleWidget'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import widgetBehavior, { InteractionState } from '../behavior'; + +const dispose: Array<() => void> = []; +afterEach(() => dispose.splice(0).forEach((cleanup) => cleanup())); +beforeEach(() => setActivePinia(createPinia())); + +// Rectangle uses the ruler's interaction behavior with different representations. +describe.each([ + ['ruler', vtkRulerWidget, useRulerStore], + ['rectangle', vtkRectangleWidget, useRectangleStore], +] as const)('%s handle presses', (_name, widgetFactory, useStore) => { + const createWidget = ( + selectionKind: 'line' | 'handle' | 'empty' = 'line' + ) => { + const id = useStore().addTool({ imageID: 'image' }); + const factory = widgetFactory.newInstance({ id, isPlaced: true }); + const widgetState = factory.getWidgetState(); + const point = widgetState.getFirstPoint(); + point.setActive(true); + const line = vtkActor.newInstance(); + const selection = vtkSelectionNode.newInstance(); + selection.setProperties(selectionKind === 'line' ? { prop: line } : {}); + const managerInitialValues = { + pickingEnabled: false, + selections: selectionKind === 'empty' ? [] : [selection], + }; + const manager = vtkWidgetManager.newInstance(managerInitialValues); + let animationRequests = 0; + const model = { + widgetState, + activeState: point, + representations: [{}, { getActors: () => [line] }], + _widgetManager: manager, + _apiSpecificRenderWindow: { setCursor: () => {} }, + _interactor: { + requestAnimation: () => { + animationRequests += 1; + }, + cancelAnimation: () => {}, + }, + manipulator: { handleEvent: () => ({ worldCoords: [1, 2, 3] }) }, + }; + const widget: any = {}; + macro.obj(widget, model); + vtkAbstractWidget.extend(widget, model); + widgetBehavior(widget, model); + dispose.push(() => { + widget.delete(); + factory.delete(); + manager.delete(); + line.delete(); + selection.delete(); + }); + return { widget, manager, animationRequests: () => animationRequests }; + }; + + it('does not drag a stale handle when a fresh pick has cleared the previous selection', async () => { + const { widget, manager, animationRequests } = createWidget(); + expect(manager.getSelections()).toHaveLength(1); + // The real manager clears selections synchronously before capture resolves. + // With picking disabled it also leaves that valid no-selection state intact. + const pick = manager.getSelectedDataForXY(12, 24); + expect(manager.getSelections()).toBeNull(); + expect(() => widget.handleLeftButtonPress({})).not.toThrow(); + expect(widget.getInteractionState()).toBe(InteractionState.Select); + expect(animationRequests()).toBe(0); + await pick; + }); + + it('starts dragging a handle after the pick has resolved', () => { + const { widget, animationRequests } = createWidget('handle'); + expect(widget.handleLeftButtonPress({})).toBe(macro.EVENT_ABORT); + expect(widget.getInteractionState()).toBe(InteractionState.Dragging); + expect(animationRequests()).toBe(1); + }); + + it.each(['line', 'empty'] as const)( + 'does not drag when the resolved pick is %s', + (selectionKind) => { + const { widget, animationRequests } = createWidget(selectionKind); + expect(widget.handleLeftButtonPress({})).toBe(macro.VOID); + expect(widget.getInteractionState()).toBe(InteractionState.Select); + expect(animationRequests()).toBe(0); + } + ); +}); diff --git a/src/vtk/RulerWidget/behavior.ts b/src/vtk/RulerWidget/behavior.ts index 0676c6c9a..5367fc739 100644 --- a/src/vtk/RulerWidget/behavior.ts +++ b/src/vtk/RulerWidget/behavior.ts @@ -62,13 +62,14 @@ export default function widgetBehavior(publicAPI: any, model: any) { model._interactor.cancelAnimation(publicAPI, true); }; - // Check if mouse is over line segment between handles - const checkOverSegment = () => { - const selections = model._widgetManager.getSelections(); - const overSegment = - selections[0]?.getProperties().prop === - model.representations[1].getActors()[0]; // line representation is second representation - return overSegment; + // A fresh pick can be pending or empty while the old handle stays active. + // Only a resolved pick away from the line permits that handle to drag. + const canDragHandle = () => { + const selected = model._widgetManager.getSelections()?.[0]; + return ( + !!selected && + selected.getProperties().prop !== model.representations[1].getActors()[0] + ); }; // Check if mouse is over fill representation (for hover but not interaction) @@ -148,7 +149,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { model.activeState?.getActive() && model.activeState?.setOrigin && model.pickable && - !checkOverSegment() + canDragHandle() ) { draggingState = model.activeState; publicAPI.setInteractionState(InteractionState.Dragging); diff --git a/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png b/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png index 0b2e24c522fe79d6d437327cd546c960c3027b6d..008c54b82d10cd19be374bb1f870a49bc2a3d039 100644 GIT binary patch delta 160445 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± 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 z zxIggm8T#xNvj(jPgCzgmh$4QulT)k?4 zP{$z8m6T9qA=!j;c!N1Mj z1>b{J@GBcqGmG2Q1-4KALD&sAcrEeh{l0IsU6yWzj)R<&aUNQxEs?9W%n364JWnNi z7MFit$YXDAqFZTV$WFs@f+3Rb5S-LBMwbP zx0Fw~Cs@)-;q9qKD=vx(;oFCcKU)y8D)NZ}vVhMYT0chvJ*+ z!GP^JPCkZ4NDFD+gWMFXk#FNRxm;GXjN&;_Uz(4l7MRmAFzvwMB1E#Qz%Xhjsp5Yz z=e8&IUdC8xnJ3%q7k3x;wftr5(>}&Qs6xsy`Sevtno}LM4j=l-W9Ds`A1xbWU8V-= z%e4Rd-GsoS^WeZw47J3Dg(+R#VIw(kV~jq=efLtz3Y03Tu$ltD-^}XIQo50g_kG`k z`j1o7^O*o~gdq!U6P~WxwVXQ(OH_Yo+y6V{BL-n8u6ooJklnuvs|Z;4rU~e?7;EXf_2aZ89{KNeUFUiGqswusLq=}~cD=_Es)}r~ zyv_}~E^HwzW3aU#`3i--=A8Gvf?UXAW_F}_Gin(y`XG~lQST=Fc?FrSKh}Ry6sI0R zdgRFu2lK^ssXu-J*;x9ypRp3uOLo0@dY_%2z-errC9FPB_ndRiPjRj=ZYZI(UvWBy zdc*<2Kx5{7!=-RY%Z3hw*!lscgjJ7zViK21F~|={)6FAVknyHRBo4E&UG=mK$x%k1 zK&l%gmtWW4=`a8eG?HPbmT_5+Sa_)hM-B+jJbB63ujW0PD_Y}f^Q^kb#FTE|FPm>M zePX!hINBIRxG`-9!r;U5^-3X<1F~kpX|vqxP%#4Ayicr1IM^h3Pnv5QRb8q*4BIP_bl_euA8nYw|Kwb z@DStzy>~q+@{+_gBUWRM&_rdl#!oHN1>h(H&`PQpO=;ZsKd!PZ>)L%CB1}*WJF7C^ zgK0-ipEz2jR8~uJBA3*QjN@E8Gp`oe0B<4dLFh||~WVmVso%_E-xOpA>REXfuA)|j<+oIPiU_-HdQIfvqo(8uPY=yo!Dp`(Dt1t{6q5QXhV z-l3aGl2O*Kyfms?n#8gMI%`PIlHNJLhs7eSAh8XR@9{;8btye}$P62b*iG>R?!k7h zyMR|vM^){$rGMD#$TOVC=p?{&tc4>obVAt1@c>GyQo_#p$oC}sJ^gad^d=8+NtthS zVTOTrET2bXcc$2u%>)TqTwXu&5?t&|+J@!e>epf82JsI|0q_!F0O&PWBzqaO*8D;5-iL;OJOkBywXd;A;xs<8TjAJIs#A z3nB@jhCDzq&DoO(Z>y@KC`Qq(PI9+)@d+(OMMG~|G|TuNDNR7Zen(cip=Uj61Z+>- zW&UZmJQ~&$%N62W0(!(MYi8>d10(Qy(GPTZ0-mTLm--k1Fn`X=w_Rec{tKWXC+`x3 zaD2~-bG3#z*MdD@1oe+-AV^h8ZgeON7tVC`3)M?=JMvoV;ZI!Gbbe#JOwT(t?#c^{ zS>ait{Mq2O1cD(_KD5ShIsBFf--DJDMQ<8CZK4vwvZ9pXx^KzOiFYhzopflq6|2*F z@Ix|iDX?OoW`E@oJlRu{rL)CNfj=jNR0eK>y8E&zE_x^~R;cM^OU4M7P{R}MF~jC2 zWx5vz#cK>&Wurv(NkUk_rbZtg4sTBKS6AA5nB7!${0`6 zJ@HKjMm~cKhhl(rT zQ`J(tmuVUS9DhbWTXYgS?qqxre&YQrXUp>|eklV69ooiL5FE-As9;{E2i;{{c0CZW4}Re(+@T6O5}u$2&WZDTh=_u6YI3j_ zx^%Jm$%)cpbx#hDJ93b3Y`(ndCim*?g}j8jwQ(rWZ%;!-MwSK8o8*I zE`J#Djn_fAf`@VI?{F$A?bnJb65)lwvt^gV#+_6cE@(OtyOm(X&^W0yYZ1N&jazV% zYimv7b3@JJ)GWhFm34R`AOm@ysRcw9-tRXT(+SLSdCkiA`0f?nBHAI#wR(a!EXq`I zgh^g`&HN3oMz)LY_#S@>raW%&Wtbgtk$>Wadk%I>7E%#P<~Sr?75SdEnpk;{@(I-$ z*+bw5xQ7-+@Y{1?V-MinZh%RwgN(oz(p<9X*hbjGQE%cVI8m z3%>Rn%Z=-R@8QA@C_ry{c6<+y9)EiMQnxR%*OeAE&X`K9x%Ir(nI-Xckt8!wReuJ* zy(Zh_wb}Y(k9!;-_qTD|kDa76MBZa1{T^NX3*l>4aBD_jv^|bCv+XRae+qJ=a_rFX zgihJQRRKtPJ1Sq6Ue7xjD1{?Olna(Y&v7i3RkboM^vGhsfq`>wDcVyB?vb;T8ahz9 z9t;zzIT#h@*Z5gt<0YCCV;X<{<$pwo?P#gB+;@YOTGMqLMdB#xm*Bxe-LKhou#koMtSwADiX~y?l*L|Kv%du87ByewYPHzKf_A0`y`ACO=8*~r< zZuMNA@7W_8zQ_mY6TXIrAu7whkIH$Sd$%c8wwWT2C%$K+yojC6nFro=zJD1B!4d$i z@)QB38Q-IcgHUBd>(*=UvgMxmo^T`|_#UbKqGma^SUPE}$JWUW_8qTO(XTRSKOw7l z{eB2~u)={v!Sdqk^an({z>7G~^R;RzB(It3`Xv;#8p98JYFa*)SVv8PH3!)vh{jc( zb>@4N>{;k2SXTEWWRW(q9)J2gZI50jKB3g%vbC^jXIK#9dpK%OajuE)gg95p^F!Vp z=QY<_5m)8Ga&v!OxLtk^H6`O+#`3(C|HHoh9Erht9aab=Wx`CU#znG825c~NLOl_r*~jx&9bK8t4i-qWm70+oZ~m{5RBdNq-ofz)@Hu*LDcC?u3)r zT5MMnBFdm=TgOg+Om-VAzvnn5uUYV+bzU8#smxeI`LvEuC$*+%wL&PPk*u8SMnLnr zZlq=}zG_QaT3twSTZ7diu@Ql^=HW6mzOJY~4`M^QOx6Vi`b`v@#<{fio^FTfZaGgQ zttNu+!ZpTS?0=j4qmFpX_XK?&ts0sUJw&_0ibf|)XWT&|cJfPnIq*n7sj4di#}-CX zxqyi;GH3Z=UNad*DxTC=|e;xo1V77^uO0 zR2$E7&jom>I0LaXwb%1z?UBhklFUZ{03ZNKL_t(VZ-4TM0fc#^AZ76Q!`frFRzLY< zP>1wklEBTO0JU(29hoiEL6UeeL%#ku(I$cy`y7@R0{&W%V5 z#GBS_PnQWI0iX!#G0a>Uq@x#?u_6IJP_XFu`Ti+u<>aquya+Mi@q%5eUT5)!%B&3mM06kI_PwX1_9%=USdc9t+6MaxU-(zu3448|hr&bT~sUiXQ_|ptn z#GF)Ydo*wR&gJifZxSE3)_aVJPFtVR&-M#=B0Oe5b>J+W`olA7IV*6_q&zUQG&~5YOf_l#+6de@DqQm`+eVIOf&_)u1nlz&1GL)@O8bf z>q1i;mgm4A62Fk9fGS>{f3I(uYE^6`LpKZhz{LXHee7Ep>S#?DahO;lU7D3V0G;G& znEb|h%}#aHT8nn?^pt$8i5wxbx%WD?TQRy2ErKi z$hpm1rT0NA6zaX{X5Lc(8=uW-8JnUF&4jBud6+-od)zZ=1(PcYqr=jzF#_GpeMHY@ zWkT!_6G*rC99_*lhORyDr4nX{*0035D(HXz9hkXrut;dDvoE#vYi~%ITzmb0jB~Zt z?A)LD2>N_k?IIa_OH^+jjGWr4#KM%OmPY6g!2G> z_=q>9_axMEHEpmddD5KUBj;i=-$Q>Y!p$3?V197M&2Dv6TS{K@<_o)R7_5jzj`n;H z3!cT+e4I;~U>(bi5uQyk3a5o(e2!O0kL+&dyPNV$4>_JnzY1}(e)Je#48BXA_v^mU*rSde3!LsXAkhEtolhikzmnN0RfHyCx)f>}LUa}gS?;-XBt=_jm0 z7f>Bdb%Wm-LavO9ou(iBOZ(xpg@aL%)|D{utu6EtaL+-?de%xE%2a;F80fe|&%-9R%LyLE2&hM#-?>Ua+xGtGKN%7&_h@h|Ke@!jga%P=kWNiIETr z)*|NgEuYE>d&V?gz1XLLW3V$O@puy{&KkuqLWN?cTK34DlZN&Q%n`|&{^gZ~fgbQZ zE6yb=QY04^3G4!U22LGP0&ENos|?MSpF)tPjgj*-ar>FD+>SaLLk8JhoJib{!uFTdxyx&QNtn3x zZgH+j+X4P9yWRdYvC3$GC6&v#u*DSjNceGbn9F2y2YTpv7Rm?l6=Dk5`hzr2}3z9O?Sb{o>4dXnBFD&>*KJaPSFC7NvFTmx23_%g|=ar#uB z6o-K@AqS3qn=BbsO<-rqbzVZH1imc#7$=`qTIqZ)Gfq@he>(ka$Wtrdb4&I#(!4Zz z;sEYxt&K62@0tC6%4?pQ4{45riG)`1Wh-vTh9D^@pYp9{@&NI&q*qY&C|q# zxV+}`l&^b@>XK1|RS4O|NYUxfG_-_4Ttte|Ki90!g#A%Jy_n!O9t!U1xj{ zlA6;L@kb4Z@k*lXoOU$aDxaDifG>A&C-6O?Eg{K+LjYlBf-|H~ zkDpZK75sYmcLLVo_tDRWpwHx&d@60~Og)w|2=p`bar>020fH9GezUw7DR#y^=+<)_ z=Xr`j@4a&YpsY(%%;YZa*G@h~v&!qbkE3adf6rZ%zOA)L_c=lBaz58A#h+7Q>xE4m zCZ3ZgLQ6L{3I5Fh*O>gz%N?$n(t9Ls9w7?B!cAVYJP2bH01=1&Nj-*Q>FnfW{~^S= zB*7AS*;|??5oK~L{A7KYTi>-9;OGJ#^5dAf1ItP0>7trfhl ze`}#wcS@$rZOm&n=EwB^w)5PxUE8Mk*o1RA6q@0A3=s5MQ7!fqCA4Yu^5cmrM>2(^ zQU*OgsOJj2Me)>YQA0Xu{WLju1F%b2Aj9HSOCfAiA| zGsCmQe{(J~_HH&X8B~QY0G1(aF?vT31Eth53Cy2Mr>nZk3aM^3F@bgof$C!eGgt5{5o*_rel{v5XTQY%e zwa2@>X0UpWh=okZP?#@tIaumje@d(Dc^nLVI1rE_EZ*VDaP5CH<`~5~erK;p8y$G zhmA3=`<8*pgdXSlnwNPm0qX%{mt`;kcmau*-Y@|v97Es2fM!qt2V`lqnQ}k+WkY7n zC$d!Jk}gI$AD1IB0gHbRIoj@}0Yn$)f|FnosBlE3RUUT(r7OWOpXF*_& zl&RxfbVtPO%v0DVelC15m#!wY;>>&x{kAr0GJao|jCqia=NDAKvPi15c{>o<*>KjE zyJL!St&U})ti0y2eHU;~-kIq|6Rp;PuT1lq1#)u+If+4-lMR0#5mblw-y(vY^TWBM zG;m#Hi*D#>W`I;7rH)WDiw-uir7cskw?jXr?9UcrFvL1OQ9jlGz42xAK^^bmjR#Lg zSn8U~_P_}RG16D_uds?dbJ#Zw+MH$WokZ4<8%^_O@F8=B#kHy)3KUclzlT@m-Uu9< z#>bV_>nuM0Qfq(h{gC3R-jCi7B$Bf}UAoV^IO~trq!-X?NrxFwR>{w{DOqO0&{Q7N zUP_UsiKvbaT4qbGd={iFbA#S9+mGxzKU|SD_6HS^^!&Dy5OgB!>mbB{a0W=A;6kH1 zf}u}%lC4Um5za6ep0s$xhu#dyJ-y$(pP=iIeFE4Gc4vQ*DaD&081CMW-nsLkV4wqe zwT8C_$Rw7fs%BDVOXSoofOqR3*30cl{10pCeALZv*C%>5FrJqumS@=e?O~g%C(B4N zbnKjY=wpo9W_qDgAr!6y`5@$lz2CJHL8T^{8{ozxN$#js&rl7LwA+y76np>?y6IJ=C3!@xfdve{XpDy#kd|jJ8 z6iWi}Bkb60!iU>=e+p&t`0*(X%x#$;;2sP}*T8+Kng;9SWJ}A{7{oC^nWVs+cB5Hv zH^r(Gnq%$Au3=D`Fti||>_(xTG*7OVvy5Qe)MJ0NkkN;VBwQ`HzwQ~;7K0QMCl|v+ zk!S6_FAY0nTk&4&N;yb~m%E;0&L9(X#%6rtulL z=sQfO zq-7F|q370_wvo%HK0I>Y{Wxy9l#n;FE)pfdN>LW`tv$pG$AEZ=Ax=@T_*vr?i+23N zkcJF%&W5~T%OKF1O<`M;^U0smmS=dH7TSL)%TOs#N(t~3aT$xzOywA^>-~Pe(|w zh%?Bmw+>PVI#x>DlrWzi|HmnP?6xI2vIcFc-(W6n1=Cn7{XFP_8MJY*^5p;9+-84s z=C=b6*s7tExi{)Nlm~e<1086=V*hB5=9SPGLtzN#C%%Vym+)W#H$>CL+9BwIKs{;0 zWg`GQNUB~efHW6&riHO)h75pcd7LAjDQ2mg(#dsQ2ZKM#Ym#dYQ&xOD+|FMB7^(P+ zv*E{JH6&&yRf*Sw^ZzvONX)|=WAuOf789iQdvJ^yxk6|(65{hd4&Drya&%T$KuT6@Ys< zlA@fDwWi!o;H36wh1P{dqWb{2Yi@Nil}mn z2yE4ss6`|ERo#ZQeU9VQPS$_=RMt1Lfu(@k#EDbF$pQD^p!MszZaveU$R5MkQN3qD zr8)77WPjT?AwM{sSe?@08%=43CFwRyZ$>^OiipM)ni+-^jCg_*4)ZVVTMs#+hfl6h z73z#p+Fxdp{0tPPYS;&&UAdj`ZTyIrpoVXHREnAB4PwxT-ZamF*|>iPN3MG4wvtlj z)au0_;U0c0BeML>EY5}RLbWq?Q*~g5G|U>ghc+n$bc*M%0MDB@w7AaZ01ZZ0v`F~- zLlD!s0{WJlcUn<=#no^48?b3?Eow>HFiSJ}maoM?kFf5l8>D41!VZhiH$ZcQ+&a;) z;tty>LdOB`VAA67Ofr8%7HEg6vucw}am+}qVfNq|IITSh`nW8I`@YZfL={gdr2+IP z=L2rH>XX85L2-Y5y=yJ+xA8q)RW10Q06RF_n`_sR>%-d>U1>m+Z1-7yA6MW8ec{v3 zmr641U`@UoU!(-__IE_u=}gv0!GWF4u0xtUq@bXo(X72Ep8N2M~8Df>cK487T_8*f=(=@ zSv+XWfNB9ANo1Jah9$_-5CeuBxaT~F9Kve(UIezxiPFa^wKl>%s6_(Dw)h@B1(ur{ z6RNhyfDC8mQU z4Xw3i9W0+g4|1Y8(z*r~?gC57=pk%B#*L)vOSq=rxwikq!2G#d!TbeFocW&OA!!NC zQ#=xthhpj3meB6~U)`mjHUI7qX}gw?>KXs1X9SC|a6i+EbMJ7`1ohd+c;Y;YjXi z!sncJ;5ig1(jCyt*h~)OK=i7|reQ(1SP+vxS!OoBHe1VQp#b28)R|6+r5O3tnqJ{a zmVSY4AOU|6w0B+C`~6i)S#hrGy54U|mICIPgg?zjlsg`%OKEblnMRT# z4!%5o6GH2ufcJ}no0nTZJFODFkSI>SHVDsnr<@+y)Uu7ngE%OuroP?U;6BDjDORr5 zDM(SFSW@p@>(GzZ4(ZQ?W&~)gC%;`e%Vcg#IZA&i>+C03bk)~9hWUW$Ie}v>6U~x7 zigtj+#3F8(z!uMZv^qdSNf?r_tvS|4TG&MEL^}=C!#o=eJhisqeO>SO8|Q%sO2$|d z1azceyY~*`DzT(eT3gCWi06yQ9?H2!KZ3`c&(AM51hznAGNHPU);LzOxs(T&b@y6@ z6r6uVC5Q4L!!q!c;a;|fZ3TN@ajq4CLf1@C<06+#TPewXABZT()(kv6Ny={siRBh?XfXKn>)9rA>|y7(Y9M z3kh;bLucF#xChC<@`r?_PQpD$IJ8B^PbZAdsc|mhd&WSV3;8_&O{EA`-vaL8ih6%9 z1%`wOmOC@X(-!22!`r*2%$Ycw{tB^~R2EngNxMRZKK%0tdVG-=m<*q4M*7#MO;p9R8?tB_15MnT2fvkF)d7+3VXzrqo?ElQJ&PmK21{CI4$V+A%_S;uXKSsU zj!Iw;gZUnb*H}j%atcv&ilcuV(aZ**CA{n@0$=)IT3Ox9gz)qmj)Nur-^Y+V05mmT zeP)SPsVHm~q=Lf=*n;BvlF}r}t#eMqxdi`p&-Z{Fg)GC?!yboBEU-1PVUq8~oUso7 zjm#b{mCxTSs!E8`Vz6V}=$7n(>k$UY<})uZ1(GZSci`}I7;^^AfIWY;_P~`g<_w`=>mz>B! za1Hv66^=Va0RlQ_ZS8-ev?}mDXsGD04_ES+=T~sQ@!;MJW0SzRASuvRnWG0D^w=sV z&0nOl5&1p6gDC`~W)ukh*FA)qK@r#ESl!5(`=3zL{taLX9eB3ZE7UAlc`QFuk3KTo^UO$6-3h!ZCm9T%?k>8_(;DrQa2?`s1 zZL8EMrHzUUI$}*_Ytkbzakq(-?f9N82n!R(a-7jj04O}~(CqW6A06S@ki)=tWZjL( z9c`#dbVx^fuRiEl0eI0G#P`JW5lflI_gLszpIDkw*tXgST$oz)!ZYa)V}?^~bcvbo zfWT!_$S?(rL<)Z-b1IUo3T@7*!E36i4Q{{hOH-ba+ymUh`8~aNZKtp%0JXZcTDnQV zJzSbZ&-5^daYpUuT+V<`BS`YM2v-W$ciS8&&~2*f_n21UwSk_2A}{RgiXO zc`06^g71GAM!*jun zD?H%K?1>q#D|VmyDYz>nGTHv*yJOJe`SUly_v|ce)q1Hy&t<|{%EvfY?!%jo`$GJ} zy@~Iq{l);*9v~!+5$a^Iv&Ya>61P^o_xXa!`r?1y91vw_9DL;U-%tq1cTUsJy%t<# z$E~nQ<5u9Ne^qL}%%F#QkW6@ei|@HE$!jKJsjNyCeej`Y^vpSGE#%b|Fnp{+dG_3z z>SKkNF3U;wW-$MSj(I1(XN*yMAGJE%Bc>=cXBO*%vkMsa2m^$Qa68FTomZ*mdcWUj z4kUk1sSOMduBWcR5&!@o07*naRG^k=Q&PWZhNJbt^SjmLi%n*xF}5@e8=EwF(HRZ6 z3X;m=hC62LL24ZSPP`pdEvl{waIXg+7KmXPEixwx$|Lt68W z?+Jj<&48Z^20c$Sp_mdvS%o`m2LL3y2f9&Who#o{*O#>H>oLD)_0#~*P)KoM+@k!~ zv|m>MJ=b;9DeQdlpK~3cg4kE5^Q~E`S9**nC)xJ42F7FlsW>;)(QyFwR6BLzc1p=yj(QZdz6I!1C zz-k38z!x;WC#wl^d~E&vkz0sEV^x1l8nUFO7mi=aRUTH)Rbs*MR8Yd^miH&Xja;Xb z(pdv*-{rOJ zOJ*NvzbrMPXE^Y0netha7EpqM9)fx{@LKC(&L@MAJ+{#It$z{R5#0K%& zdjV6?-)+$p40h#UHf%|ia35K(PZ#IvmVYqDDq+9XkaLdhHm_MDJB6@FAy_BM7%T%e;dBQ!;qro5@ry{FIbq`ub@(2qbM6XZupGI1)nZ zf;xRn_Hf78Ny;S>KL3AsNzc6A$DHfPCmaShG;CLr6KMEe-c5fjwQ*6jc`-Ag2pr(X z0Ht?$JVobTTc#(x_;fE0VI%b<91&xYVwGnBCGYop3e_44&E3^Z6SAm4dgbUCRBv7S zLU3`;sg>Xh1o6z)8g_Dd59H7l%dS6cEd|_bY|J9rD=a zPle19+vSSDLI^KtWo&>)=#zGXK_@b?mes#|Z7xP`2P=O+<21Bgk$lp%fmKj9E5C>G znvvgwe$4^*+*H9-N|h@RRctynVAB%BkwKeZZqS}3RBu6MxKbRXip#922*mduY6L6$ zrcb!1snMFHOo2G}-3j*~j3TpiXc{5+YnGQriU)TxO!lKP-xCbXro*Y14e&isuVEvE zS3np{`LKVOJ*PGkp|@))&7R++RE`+uA_Bp%GCk@4_#gj85;-_zah`I*rRb*K`=X=2 zfB*jV^(Fd>iumezzFx1TyTpp9OKnP3u31}cwaMSausYUXp8CK3*Z+xnu0bxx@LA35 zDBIIP$><2&`G~C*SnCiF(vT$@KzVI^jvb5aB;aM!qmTc;p+MGiMcKdVVhv`zH^)EE3pmxt0V3(Z| z_JTxF2DV;y3;H`-f5Z%x!khdlE!WI&-6>5yKP3QeJCsV(S3^df-8XIuUC1(5ZbU7m z*5NbYaV4fR$Q_*uj*XKVbJjj*ovl@gmg>)k2#6QAQcAgW;~2d+=W06lkk=eFuZOS_ z${B=3Y~IG0O^ID9<_d-SylR>6F(gXge}dGQ0SGgH|2e;BfAZfeJ+2f9AJ6l|zIqui2L9RwsQebMfjmEz5BYFrpj(~MN(;x>Q$jn#yyBw8HqIp9|oi+bKP@F z&+C;2D?gEQ&GaF_Ne%AV!~S_dErCsW;(LO9;(9KIKAZ1<%4^;to6U>A{Dbrf2G=2~ zRZ8_Oe-$t$e>qQ3%R?;XAg-Mp=rkJ8JYKE|uF=}f= zQtS8tiu<~R{xlGwBn{7Sz?ufT{HHRp!%eS`bLq}v%7?fUg9u^oTj?VF5D${VXahs7 z`8_41rhyXUAXhL;f_spBh8?$!b2S3!`~?gWf4C<=AB`hd{vrRiWIu|1>87^awgg+V-2Hz-?J#6g4Zeb&i&l~@itaF|*LvLikIj=e5o-*g3{^_6o^FROd ze{X;L+u!@W-}~)vfBTnz`IrCpZ~yj3fAmM2RpH0X!bhi+7gEhIT;{>6uH=my=Y@oz z1c5*^P_in}{ie2bU=i&5w~Km<4pgz|`HuqQstB~!S_X$;-KU09=@1;E$K*ex0avwZ zk#0wp-y@Y=#`pL-cF`ZOz8t4->9`!Je`8l$YuDG|n+CPrq7N06h}#cL2a*+G33li? zElQv+prCp$ft<&adfcG@p!(h?H%3(+#4`g&PjvrHMgRccm;vObaCmvMq* zB0f8!iR{n^MU@Hah-oOV8JF^L4TpO^pdT7!=*1cbAT%T>AD$CCHIpUBZ?P?ee-qI! zv+9w!(R(5&GN3}zwV3i~f+cE|s=&cYthGAkqEfjFFiuMVKd;=;V_tJ14Fil;ysp3! zfE61a=%lpfPkGInWTp;`U)(V5HO)9FOZz&p0*zlR%<>4@XveU)Z|!6EwUj&Z5V%LS z_63c6&vl`bD!_Dyu>FuJDbO(be_-&Y;^L8TTmCTNIgWCyC1#C?8QvhD3YaT=PneeV z9mJKwxZ)_Kr^Z-uE?UzGS98NSI15(MHAZDM%)n-H^!j_vr9*|mB^RE|9}$d9zX5bk zo^u)ZwHcQYqGBZ_!C7^7t}^(of2DrFPr`ps{C^Cy4uC#95s`lo;Te~Z8Pi{Jd_ zH-Gf&Uq2B_8~0tT4nJFtEmO$fu{9LuipR4-cM~$Lx-Hyz)X^-Y28?0ydSv|syN8Eh?W@~!oze}^)A#s5{e01H%^s1O(4^F3@!Fb=Qp>X@T&d!AaQGFj3R zYb{ciA*mh#_wX4&z BPy|Q)N6Ze&7NYL8k@f&=B242kvto6k55;B?r?cT`>sB71gj)pE0kZJ)D;jPXQZ$&`s7f zbD*ZjMYwXD3sZ3~w`yHha;J36!-!Zt)f{|J*HZ-peZ`qQ@_%$xLUV+LdmyRd@je3j zrxyoh3>~Z@O2_x`)u9i=mXDyP6iv!@pe+CwCpJWn(WnZPHxB3mf>~RFe&Vl}hQ-R6 zR4ZNvmsx1?^m=aw7tAJa!VnXGbHOPen8OGF6pR-1YaaLuOvm&ziIXoe2He}!?8fiX3d>kr`81AgJYQBA_Mp6 zYgPRV))A1ze2+J}!Z%12JqQkm0NASwg)l4tPl#u&wdCI>mGuN`Xu>An0^i5-ZTt7o zbvCS!)0sQygKtvE)g0r0;vYPUNG-IvYb~R;T1F{#dZi&VkJglhDiE9;Y4~R1?D1Mh zA6-RM=3&-W=WMl>yN@|mHi=Y07E&oP;DMcBL$>)UwoBZnb%D7$)24HU-#%|T*M##b zhvm-iGv$+)Prt)`RVF+u7Fv#Tg*<)PK5{kLQ%Fm9aV~9`yUyo-TDmIY9&~E%y<=bI zq#|L8nr`gRK&S5hX3WY02^rE!IONnYW8!fI0=p1$Zy%h~I5a66iA}kAZ1v-u?w>U% z6FouE_3wdWxmCfolztc6&f$zzW=%j2`GL`N%#ujgSOD)GVAEedpOuU&`3yR$;TOW9 zlRQ^mf!f!6pYarE=PIR?@`r!;hky5XfA?p9_Gkb4umAdw|M-vp&wu{UZvN;=K23*z z!te$R8#lzvN$KH941Q7|mSag)s6}KCD+yaC413GqJ&=~VE=D$OR@B=kBl`SuZu|q{ z`bx2mM0OSrT^u?cmkLq=Eq@o@Y4oJ`r8!_hlT*!+Jmdg*74Z+a2UkO!OByw6tE0P= zGUs{UFPT_L+Z4i#WOl_pgp30N4>#B8DQW(r2$D;>0*4Vn584~=_@1cWNX2m3GylC+ zi5pohw!e2&rtUS4*J##)U~#kP1YSAufc-R<9PEkJal^ZUa0K++(toMMISbly5rHP= ztXc?NYhARe;EL8p9c{K*we6lf502Uw^^6>mxgb92N-suC?Qkt}!tY*uYjsr5j68%F6GFe2*2?A|di2 zGT`$pn$CwSWH)`XaXx84P$S7x4S7x#8wqTX&Vb2J_Kh;-$ugfJT!nRkbN%%KVf*mY z5nLuYdjP-ur+0kAMGh<5Sqvm=F}t`vD1V z`7s{-qgGBY)`D43Dqqn1fNM3=T5g#}RT2rE&{geG!T=r~)L|@-8u`|?w0>790evXj zycxLpa9#iYIN4o&Wyt|NhVa{Lla3 z5B}g^{^eh+qbF$rh|&D`;$MaNeVib_RQ$A2ucue@B%>iA5y?u}fklcZ#%@vE-J-49 zvt0h6r*Qzf!(Fep6dWmQKO6hyy%2ewdbqUfEq?}5LH<7Q?ejdP+sP?EY|9H#*9wV2 zj6%Vwy2;GXs|qMW7EH$jG;|RGKf~dhVO_${<6z96=KeARPAXpAPO!&?t0p9ve?Yu6 zGuS0$-3FT!9C1IT&i@Geg{3>ZA8f7rbO+SJE6B{dv_SlBG06W60Hlvf_&OBngX?sG9!PN!Mv=)6iCg z-0*NZ(scKnw&eUaDeOtx&|QR9%^@CLvVUoylQuDJ$!c*4!bv9i)Rm_?%`BG_Jclw; zQPJmdoaX?)KtR9Qj8QI4`%Yp0kz}32qdWs~qL`Z{B{+tgZ_|JjC)v0)JYza>Zc)4p zEt8@1;ew}LFS&peS?%gk&i=*pRq!e4$){rI<7klQJ?{DCmtX$jAO7KQ{^oD^$DwD` z_LYBCc{Wj;qZ2Z(HW3hhLd?R3D|afMkE<>8et2*j>k1``JQc zR#pTXzL584g)$P2bR37Vohc3iJH7PR>-9QcudKy1n(kVa$cxT`0lh@7058fOfXw~h z4?IUy=b6mJTF@BCdPI-`^MsiO<-^C+TcdwRwNt2P;c=4iuvZqI88!dThS4+CtO~O( z+u$BLs3c9ZDQkbD;C03{)l3_tXK}*LqMPYk*yHR-aJQ|^9YhD{C-4teYIHHf<8Nnr zGQe7j@Hc<_ z>--%yx@>nt#XA%&o)ly7zcO@#i|DCaooz9DjN9dW1VeZv?m`_FlHD+WeYXAn*W?mm z%BHp+;Mt?P3Rr(CTPb=VP)ESM623Vo*TTN$e9Jm!G-$~6xa_S#R~M(E#vI9W|adL z2M5WTYwvp-(!#~Q-&t$^)DMds9AvS`WUOdgU0H!1QhW!5H(R2Il2p}pcl&gNT%LI{ z(tt#sJjR6`u(EafBhlvX=U2m3%W$G}L>y=WVp5z-^>@Gf-S2+)I}!QQKmC8xzxa#4 z_~n;h{)gZG_Gt)Gk@Ku0Vy{7ypI5p{>rN{;Tw^)mfC|E`iMkL`jlpsu=9RCZ;}igb7ALa^n%NGuCRRId%k^r zef##!uD{K7qg+D5J*-zPHD!Nz4P5dc4sLU5dHtXcRn7?mx1f~QCZz%pic)a0{F9;M zI@fFYj*R7W&yBndQv&Q59;7BAVoW!LF`Ar4BKexPeIeQ;{0gdz+gS zmrYs$8h?)5cZ@LCVh%5)p~*VhAwQ(yT}QQh$bM3jOvR z-gio#G$o*?#`cuQ7z&R8E%V+A{KYw3EOFg!C`U4hix&xaP%m8SkQMP~1moAI!A#KS zVNUT1=&z$*-T*a>dYMpLS<%M4LmcCf+J8prDXHo-_PQ5-BXsBz-w;;)-EI*v{xFQW zYsmt_;5-D^qugjCoA6(TAISmh$9WhhFVYb81N z-2hau$DhMQtN6~l8%#zLc%V~s6EcL^%r%qt2xl9KpQ_8o9A5iRc1M-x46Z8euzw*# zAA1rwsTPzoo@ajS*Vosa3)RH2IqvK08zNohOvW@unQ;-H_0Xkx3hejkXg+vVmSpz0 zNQqP=nysgLo(X50fZoAyF&o6EPXsXxdz+15k$c_?#&R70a}_XMNusKYemJLM@ooE% z$aPrltA4tf7pNN&qxh1b#iG}zKYwzEi(-y$^Iyb(0aPxxF5=tZSz-b~GJBTvrgyqI zp=eAQC=3C{B&YY}&CglgqBoY23_naAu=7?=R%3h1I{2P%-@bkOh6NzLDZ}GjcQbpT z0d3RiiS`nG(88Y}PmEJKpl9jf`3LCp;PS%5L-7{(O|7PgrA*$aN5{N7G=EsL-IYY~ z!@rL8z2fD`aHb(0stbo{uq&2oW@HOjgn@g^-{X&xb)Le89TF}#J*TNoxq9jx6 z>B-%ql?grU&&t#gTk0||&VOAM%@-05r2##|;S%iHV}_$N1au=8$>rnJiT7F=7G?_e zfnDG#$>o&3egREGGDgWw0UqVuzL*C>paD$1FfXS)gM)}9i1Vl`;{ESk=%i`|(9ee%%fKQzU; z_BsLd3F4ZJrKN9ksDFNY6?&t94fc+D)v`nzFyF&tnhu?HL8+sj!Etg&0K-`aQB+Hw zKP=D(=wZ+FL9mDO?k$5p?t9(HYvwmtzq<>I^$RTsj7s-PRT&wS+v3NHHACV`-O3d-R)SKq9}j{@i^l+PhdLsQT1W$3g2K0(^;UVms36?YLF`B07@lfSTZ zdE}snc?l-j;Ruw!bFHO)d>{E9DNjzHFp5xOWLe>G$Hmd{G^AJ-#e_T+rDVS6;Ga;U ziSM8m)~2d5{Os{D29kS3Rqvbgd-9^F$!d7lw%;Np&c(`)pI3@kp9?GJIqI%ym_U$( zODSlweqUc-U$562=-fkS-BAkx2_$F|7k#^ASjQ-QoiCvbQ@K%#5%BV^~}+!F>W zHkbNdEj4w)UTvEcKjR)r9w!y#u=mxXS6O1p-_-?V>wf|CgKqP?aeca94{JMqA*cvn z#^)f3tf`V-51$mHws&pu_~2%GU03J>9*~(qkGCgle~cbf3B|cCk44&rk)Tb(gPo9A zK;OQ7yYG45vIQ>kd)}z8i-Gjvy2hB;3nNWsR7wNwFEW&+v*>!ZW7;k&W1F6Nv^`l8 z4c5BiHGixlYfVFYC8e)zM@S?}&Wi}BmTNa2)&g!I{TQaA@I3|h^yiemVttj{@glZH zRD1eN)KO#Wh<0|J+t7K1LpHkK`H%nc|MC3l|5l%W{?Gr%7BCML#Zt=~g7{M>r9DOR zaHOAU0Au{gJoj7#{uJnQ?qMGP{&zZ|kC$XPjDI62pRcd4>k^T-PoSR~@hQKD+nM%r z-DR))v@Ab#qN6V(<037S%H|zgy$&^nPAO8OM>eb zmZ`~Gy7qtid#2OtZyXe8!+hVjRTr_hjxju~`5Uqt%;Px5xuR;CwI>4+??|w1>Srvc zs(;^kRY-;lUDGDb##4~_<`4z8ypES+a^2)mv%5Is%`6HwvV^!zTKQ8hD@XG`Db5xfXN7pCCVW6^8NBc+N%jmz?Vt*pX zz<9(2754nLLiw;5AI6>D&v7nf>HznEIe(1N20w%?U0>f+-y%EXd-}ngypKJ)s;X)S zvwMB54^&K{YS<*;0(bi1S1AGF62poK@G8%bS&HR54r#BYvgRBj`yewb*mGTL&UN2! z&ufkr(|)etV~SGq(OBiRPjMH%F8pB1Yi6BW7C)PTz7UQ!l2r}+8}P%5&BN`TxPR)e z>-)K$z*b@opPu;N_v18Hx1mkASm{|RC{6&)C*G7-q%}*=FG1}(n|Rt`F3#bqbER*H zX}%E(ge&n7ITG@Q&^Gi2qiQUN^30K|2(lz_oD1D211xI|?>e&k4^rLJL&I6cdTH)y zJ)zUU5c%zR6r{RMa!OuiTqRBN!GC_)-xwZ94p$MwK6XPsz=%skb%5`=uCME|A-YA@ zoDqFl9%#e3*DO_v0B~BlH7WVt4N9*3dC~|^iLiMbrbYKBf`g>!;>0fcvg?aS15tD_ zKO}Pv>nGz}Dcj4XKCMarEA5NRIsb|8DQJ`zG4tUkt$Ioh$VHa8+m=SL)_=?%Czj}e z884wR<bu6y;|PNjCG%opUTA~dUkoN%biq^htDpT;&C(v(9nx?%$6cO&r6*rzQ>ft z?)w;}ZRWJAE(Y->%*t`D+bJKCc{^cd>kn+qZlUKk!kH%s7vC?JD`f#Je++flx$6k@ zvL_RV$(=--{qlK7sqsm&DfLNb<}+J-Y3A=|J8{-2+E z*`q*3NXYD3gSmds1K&e4AEDRh(J2eN|6M1Bj=D{&+9PPt2>oOO!I7=hRPg}~D-9K% zncm`#tJ6X>o{Mf>Dp^|+f4I?7JiDwSd|_Ww0S|V8q8I~oWgczyq7FbZY=lZuMR0n= z_Zaf=ecgKyD8J_})7ru!951e#J7b#sq$5#Df*~HqWhD)))AM+s-FoCp+>PUG>m@QA zrawtkQe#-o zvHik;Of8qUZ8&phe+JhM<*k~2{Xot5czyJyg3n@Um2d0{< z^-!G4O}^+_^S+Vd6S&7kVnET2vH6~F-@d^a@NB}F5}`18CC=?5!@M=BVDs@Zv1t^2 zKOyz*b=CcJSyW+>#?ZC4EeYB74PSxiQ$iAvAlZl)fzRDdf20v~apXs$e$Nl{dqUw- zb)(fTIO>T0cQlx2E3P$Ujb3o2Va$rh&$r|+N%xMK(F}g!N_%Swu%(Uq>0AvEazth< z{FyZ}Q#)&^$l$chT*ku}f2|_3@*p3p#AAr)=(T_$#Zw)Qc;WI97;owk<}umM#QkS(SCqFRzQsWo-}Ug zH-kt@*27R}lA+ue8IvYUEDKwG0&f!dn6{MP0F{f8njuM4IXR3mN6xHPdRH7mk(C9kRt z!f{*>4|1HDSYm+C?7c}q%hb96)7X_2=aBl%f629l;zMOK)#>5(7@#~{$9?SGWZDtI zD8^$}90fTz7Uct`qw+mpYpwU2_jA3C@j>bdI*XVfr|D6wE1|T5+!T$IAvxO>CG4U- zUQ07u<^Cj8HEERI{dquAmrBi4g zNJEY>#@E&x-{!nu*QJ!CQn8dvEW=ZGf2{LRQC&oD*6;D3!T0zO<;i76pMnWora9b3bHGtU1o1xbYS`mx*Nvgzdw4l}Uh@K1 z_QZm4ASI9weR_Ujc}#-fCm4EklXd_rxj)<6S|Za}0P`d_9Ewc&kSmh-&vC9uXv;|7 z1&V+MK^{rakUz}t;oKBF8s}?1eQ@l1h48h3zUGYS;w6*^^xWYF_W-t6t7zn_l=+)| z#-$z}&nFV3;K|C$4`jx+Ap``)*9n`k&i6*JmQiYUDUjFv6`Q(tmQUoPIK2lQR+lhq z0Th3Va~=4vsH7}QYz6(2fO=R%(TGeC_RaIKt8@cd@<7`LmYI_jEAHu9ru@|To>BmsK~-;I^PiL5>GV??4fB*O?6QLPov!E8H=LlhwzCF=b3^jNL zxpM{R6o6uZ?-ec$FCBhxigV4Wmv6m+GKU~h97pr1}VY8FBK zy$*dbba8XHI=9G524gQ7)*nxu7r8SrrMYam(H7l3d zYyl#FsXA|Ia-ZT{;u2q&frDURE{K;~q*CpG4pmj1I2TYv#<=XJF$AfdFfDiy@r42h zH(AvOrh%!5j(eEzF{ip%^e2bxgJLR5$1Y|9uZJy=ol^GZ@avu`37y~D+Jigd-+XQt z+y>NTD?ROY{Ykg@_vsszV;P}i&G1L)gI_;??I%C9dXkK0E(ieBZpbz3##HP>`#| zU@FH^2obOpU{6pJ6LacIod$vl78fj2ftj!4_m2uYGdxam^EhVE#-r5RFUR0 zds<$z-@6LxEpaaHR&Pnt{e%ti8<T%2VNhZ_N5xBuMInhTG|KzAifdHL zU>4k?A~ULnPMB>s!*^n{M05~;?5my3Ggs6?9+%Y;Z!vkP8eYDB5#tK^NrXLvo?5Rt zfgbE>Np9s=>R`=W%~7 zk7QB+>Zy=pjG4|pM~aVUJ|!V{#h|hZo{KjpfpGPLX@a()GQtq5UB5?vRaAeR--B%2 zdO-s}J-_FmFVQ_1kSB-6?!#$G^n_znB}?HvmC%MxPp(@x>3Lw2{^fCJZ}G4v#SUZw zxk^LCs<0Xp*dp&5Z;>$m=JMiKVc|?SIewsXc2dda>$;4#=6&oI_|MTK>|?)b#g{fr z3@JG2o5u%sDZ1!V!J(sns*pI+NhmacOQ2F3;CUow!fQ6=pjBZ2!3s6kpWup63^vb4EM`R z2fxuLH1CfOLC~zTV(mQeJ^gis*XePt+#~J9dwP$`OJnQssSRm=3LHd3>R1#Xc{_qi z(wcodz2xZi6;Bs`hqxNwlglyC(-M^Va_nQKVw~!dFo-X_)bOahQaUd3TILMw6IQ@O z$Q)RJM%A`=ApUhRi`mD(_9QjFoyLFV5NwqnE+j_6!E=ER6op~JwOpBaMtP~zj&liV)FKK(iw=q8N6^zbNRSRiiydBs!O6xK`6Si|N*fTXnP_4N zUNVJRMbPx8n%##L$dW*N_dFhlK|U0)O1^w}{Bs*T|*_w%hlPhEb%vpCm0TCf43!Au2L(7nuyJp(|M{ol9Cw zezmfh)+*wE%5E2I(gtwZ=J}(_smw{>sTrv|wg2A1YP{*{Y%=0kQyHD_DfM&tMKnsY zOkx>2h}ChjGVPK2+tu0=?)m)TnXUn{6X+>3uIe{=cJjVJoZqw zl)75hFGQ5@N&>mZj@57&9NVC5Hq>yBRf`4gu_wS78v?|kiq^K0*UU$Jgwdi+uet7s zjmNIsQLxSd^sH4*VmKx?)RMEIxMB=MWDKzb;LWohW63lY$gg3OLjo^Pkgm%E8xB70 z3q2rzC+f#}%>aEEyF=$cHmfLzn2dJBOi|mC2jGzZNJGPQ3&a%wKiOH$qBw9AX8Mv& zt>Bt6AJ2;E{xWpggAUEe+jmWJFY+g}5(O|&8*BKQw3Ekg7s|vbKLM^H_gUuiU=yIH zDO38Fo2w+yF)*2-DUNL&)#9%|PQYOzpcJBi(gvYPOCe@7Pl~M0R5zb)kIQD@7{YSz z15OL`mc{&FfQt$2E_)vMgPA=3CkCEWN4o6PUUdJ^DK-g?=w+=nb|lY=s3T1w4?Qi7 zV{&V6+wq#?P}`5mxzZyVKXmv=V3%N}Z6KGz2%P+BJh^^J&J`0UWzwMl4)uz#pRhrH zw62cYh-%R;_G}hrY=K6#ljXTvs8;qnY1S;ritO{j7e9@)9@FxfjYYH{^m*LJRZ%#A^h40 zt?TeSg!p0%3Tu9HaX;(Uwv}yCu9-nbfchFhf*{kwCD~EeY zv8M8iq%Q;T>-GKn>(^g@{r2tKufP8K>#x6mzFx2Q zea|e6%1}>YUIDm7}b>~KllXpa@RiV+N9Y{J)>|i zwnFgsC=!^VianBO*P_8p4pygda8@yto2!OuPGJ0CcTfJML!vSQoe=ViF~2#)N!Bz| zP-Q7q`5`QCY~p^z0F||?_u9CB-*e9U=66j_GCf0|L$1lOn;!(i!r-Q^pk~VWl((ag zXe}?{wum^RB_^>DE#X+~?3T*MFz(4)4Mx)AFr})9&vN^xH%U})O7r9=M_R6D(b~x_ z77WKT`L3eP@M;U7TerO;yp6`*($&2hNWkGI52W$o66yn(B$fKij|d-s7ZE_et4wg) zj6|^oU#=5cEuzK>k+84H=V|R(>61*-cE((0RILIW6|A+&ZI@C-78v`L`;v!Hj4rxU z0m*~jD>^6c=VwuFdkRtqYY9-kIxTQGaH6b0YjBedoNlySZLQKan*9

H6do?m0X$ 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 delta 160512 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 z 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 F_KuWqp4&5a!DIq8bNO!}~CEZ=p-SExxuJxU@ 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 XB!qsklmUvb00000NkvXXu0mjfQO90} delta 69526 zcmXt9Ra9I}x5Sy?5CVfc3<<%5JHdmydw}2;oP)ar3GN=;-Q5Wg+}+*%pYN`9&syhc zUe0vyu3fvTx+E3!FBMd|{t8TkWh$=t&aKwm?}`r(4{dI{RrCJo;5wKrBv^;mi*U33 zj2NuPS*jud3wxWuUQz*Yu0AYCAXcGGSES457_&Tp_z$he;T zJE*<-xC#g#wE%<7OsD1*xs)7!;WELC>Y2s+;591Y>`5N&GC9^Ji%-FU9r5{BxAfzRcJj!_o%8VusO*xKO$ zx*vJedt{fEG+ie0j-yh^texIiU`G_BHd_a|C>#tw4cm47vlD5d1I_JalKVxr) zRR=Vo9nuQvL@1WchGaF z$Q3e~5+hho|8tEnWa#8Jp+7~l(1!WsJk30A=t2}iIu8Rw4kPtdScM^dy>ixCd+tk_ zxXP-axj<776F+x#u9sy2uZlaSu-KL5AFcNHT~Q(>zp=cV#Yc0 zg?zv8nlL0*c@ACJS5?)}CqKOnixSPhdVA4)qfek8Y*D6n6-e`)@ap;#-wURHwMG~)Xxy5{_% zdj}W%ndIeTptRV>oiKM?Y--^Hyu8gi%Lt1Ra4F`y?@H1+-(2-tuP%YG90ky+S6h5t znEHXv|hb+}>A%zyH42&`!Y`^A~=(9*AYIvgo9yhpCbqY#<5CV3&Q{ zU3;F|iWa=m&3uv&xV-%DEfjD*U3+PfDMA!7nM6_KK)rhpTZ5o@%CF(*K){=xcP zw>YrYDFn7`!Z~n4x(=##^rUG zhJ@k6TI34-3qMUbwkIeDB7T;;te;WDCG3&fqVr_LKeZ_S3K^4BhG|!xb5`Xpo*!Xa zyIXdQ=0EQ&iRQcf^0wjFEs?&1Z0ssPVI-34OTdgGN@2RhE`FqD`7BS2=A$y{wh`Ai zR;Kc|9*|X@^A2)Er)tCY+~0nRWN3bQ@rr(b!4)-UtY=u})1?g3`<*X|^Me@l9d2LQ zJz6;L!cbZG+T)8}3N2{pV;CHyYj<|G4;8sZt`IYI=j%B{dKD4U+itC$RaNbA4;nh-pD-qhT27mfx{%49lQSQZZ5mRkhs*@7 zlHZ~MDOu;jr8cJnr3f*DrP{80{LlCs=2+D9PJ|ZqN zV40RdYLn}hUK!SyI6|olk#ArwF+J^SYio;$vz#eWCPxL80*dHoZD;MgOu9T?kJr1q zwB>oXQNzPm(sXg}nBDTJ^W?YdL`jdxG3*j~a#%JD7^w_0BP@!5&>Tr=u^3&gqdp@| zRc9=SIhVx>XqI6Qu2iD_ zfXcRNE<6V^apiH^~_=B#~ht8}R)DLMH-E{+C!B**{!S zwaD%yQ`EiYT*o)=C8}x(V$j`5yLQ*5K=`(y5ML!gmuo7N5Rc&Z+p=Sg3@h8)cdC9^d_-@{@Ep+tOOGg5$EywYGS&6yW;6)Eh9+a z^&+|him7JfX-!^FxB5GUAbR-x%N&i*iHk|hAeeRI9oUw!=5qWInsiC7-s0lo4fP^t zUDZ+E-#c!Mi9^V+B!J2fW8V#=>j=?TK_)CK*~3cVEMh#-?&ua{R8Tw6%^OUo=J0Kz){&QiVO7*?q0cY`!P-b97w3pSJG1=0a{M*6if1NPG^)l zxN3_mQk;3SlOfa*F%uP$E;f^vu}+(TLw1_W8utfnG0~1VIav@NT9+h@fP<^+zOR`a z!G_##f4)k9xl}R5zq8G|%)YbSSCb*s*vZ<&Y=bV!sVO2lI(qMFp}!y0S8n}#Xey9vXx9eJ}vr%fD{RzplN~+%UA+oV9zL<)?98 ze}{N?c5ZN`m#Inr@vflbvaL&yPX5xu!A|?tikpSyHrI9iw>Q`){X@%}+yGuz*(`fV zhyM~)y;%REO`+pj+nvC9Cw!s&S+6}BuQuNV%rn8B#ikL2E>22Uef`)}6X}Qpj>Nu2 zC{hmKak?yU{G6|^$om>dSL0tzB4≻GN!;kIN;m;AXFEv=6Vl|NOvv+H%$o#T82r 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

NNt{FUAZaESd5}j^aZYW*0q03+3^;HeB0~vRLZz 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