diff --git a/backend-contract/README.md b/backend-contract/README.md index 20e03d85c..e6fc24fec 100644 --- a/backend-contract/README.md +++ b/backend-contract/README.md @@ -60,7 +60,11 @@ validate `annotations-file.schema.json`, then enforce `validateAnnotationsFileSemantics` — every nonempty `labelName` must be declared in its own tool-kind label namespace, which JSON Schema cannot express either. Backend conformance tests must also assert that every payload under -`fixtures/negative/` is rejected by the combined validation path. +`fixtures/negative/` is rejected by the combined validation path. The one +exception is `negative/wrong-length-color.json`, which only the strict +known-intent union rejects: `result-intent.schema.json` is deliberately open, +so it accepts the row and demotes it to an ordinary result carrying no state +action. ## The neutral REST surface (OpenAPI) diff --git a/backend-contract/generated/annotations-file.schema.json b/backend-contract/generated/annotations-file.schema.json index 1652ded68..ec4c0d5b9 100644 --- a/backend-contract/generated/annotations-file.schema.json +++ b/backend-contract/generated/annotations-file.schema.json @@ -24,12 +24,14 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.", "type": "string" } }, @@ -46,12 +48,14 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.", "type": "string" } }, @@ -68,12 +72,14 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.", "type": "string" } }, diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index c9b2ee04e..ff8e661b1 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. For a labelmap input the client stages the active image's segmentation as overlap-free parts: when true, every mask, spread across as many files as it takes for no two masks to share a voxel; when absent or false, only the non-conflicting subset that fits one file, with the remaining masks omitted whole and reported to the user. No mask is ever clipped to fit.", "type": "boolean" } }, @@ -1701,12 +1701,14 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.", "type": "string" } }, @@ -1723,12 +1725,14 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.", "type": "string" } }, @@ -1745,12 +1749,14 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.", "type": "string" } }, diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index 9e08b404f..fa94c3c8b 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. For a labelmap input the client stages the active image's segmentation as overlap-free parts: when true, every mask, spread across as many files as it takes for no two masks to share a voxel; when absent or false, only the non-conflicting subset that fits one file, with the remaining masks omitted whole and reported to the user. No mask is ever clipped to fit.", "type": "boolean" } }, diff --git a/backend-contract/processing/annotations.ts b/backend-contract/processing/annotations.ts index f846a1d8a..2b6222800 100644 --- a/backend-contract/processing/annotations.ts +++ b/backend-contract/processing/annotations.ts @@ -89,9 +89,19 @@ export type WirePolygon = z.infer; // A label's style. Every field is optional: a label may exist purely as a name. export const annotationLabelSchema = z.strictObject({ - color: z.string().optional(), + color: z + .string() + .optional() + .describe( + 'A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.' + ), strokeWidth: z.number().optional(), - fillColor: z.string().optional(), + fillColor: z + .string() + .optional() + .describe( + 'Accepted so an existing producer keeps validating, and ignored: the client draws every rectangle unfilled.' + ), }); export type AnnotationLabel = z.infer; diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts index 0097d72bc..588009acf 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. For a labelmap input the client stages the active image's segmentation as overlap-free parts: when true, every mask, spread across as many files as it takes for no two masks to share a voxel; when absent or false, only the non-conflicting subset that fits one file, with the remaining masks omitted whole and reported to the user. No mask is ever clipped to fit." ), }); diff --git a/docs/configuration_file.md b/docs/configuration_file.md index bff0f0fd6..ccb045fe5 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,66 +149,127 @@ 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 is converted into `segments` at configuration ingestion, with a deprecation warning. Runtime configuration contains only `segments`. 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" } } } } ``` -## Segment Group File Format +becomes -The `segmentGroupSaveFormat` key specifies the file extension of the segment group images +```json +{ + "segments": { + "lesion": { "color": "#ff0000" }, + "big": { "color": "#ff0000" } + } +} +``` + +## Session Mask File Format + +The `segmentationSaveFormat` key specifies the file extension of the mask images VolView will include in the volview.zip file. ```json { "io": { - "segmentGroupSaveFormat": "nii" + "segmentationSaveFormat": "nii" } } ``` -Working segment group file formats: +The legacy `io.segmentGroupSaveFormat` key is migrated at ingestion. Matching +old and new values are accepted; conflicting values are rejected. This setting +controls mask files inside saved sessions, independently of the explicit +segmentation export dialog. Existing saved-session encodings remain readable. + +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 +307,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 +323,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": { @@ -312,8 +353,8 @@ To configure a key for an action, add its action name and the key(s) under the ` "showKeyboardShortcuts": "t" }, "io": { - "segmentGroupSaveFormat": "nrrd", - "segmentGroupExtension": "seg", + "segmentationSaveFormat": "nrrd", + "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..ec2b8f37c 100644 --- a/docs/toolbar.md +++ b/docs/toolbar.md @@ -14,11 +14,26 @@ 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. + +Painting over another segment takes those voxels from it. A locked segment keeps +its voxels: painting goes around it. Turn on "Allow Overlap" to paint over other +segments without taking anything from them, so the segments overlap. The same +rules apply when a polygon is rasterized. ### Rectangle @@ -26,7 +41,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 +59,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 +### Segment configuration -If VolView loads a JSON file matching the schemas below, labels are added to the 2D annotation tools. -Example configuration JSON: +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": { - "big": { "color": "#ff0000" }, - "small": { "color": "white" } - }, - "rectangleLabels": { - "innocuous": { "color": "white", "fillColor": "#00ff0030" }, - "lesion": { "color": "#ff0000", "fillColor": "transparent" }, - "tumor": { "color": "green", "fillColor": "transparent" } - } + "segments": { + "innocuous": { "color": "white" }, + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3 } } } ``` -Label sections could be null to disable labels for a tool. - -```json -{ - "labels": { - "rulerLabels": null, - "rectangleLabels": { - "innocuous": { - "color": "white", - "fillColor": "#00ff0030" - }, - "lesion": { - "color": "#ff0000", - "fillColor": "transparent" - } - } - } -} -``` - -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/PatientStudyVolumeBrowser.vue b/src/components/PatientStudyVolumeBrowser.vue index a415f55f1..bd204d5b2 100644 --- a/src/components/PatientStudyVolumeBrowser.vue +++ b/src/components/PatientStudyVolumeBrowser.vue @@ -13,6 +13,7 @@ import PersistentOverlay from '@/src/components//PersistentOverlay.vue'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { IMAGE_DRAG_MEDIA_TYPE } from '@/src/constants'; import { useViewStore } from '@/src/store/views'; +import { useSegmentationStore } from '@/src/segmentation/store'; function dicomCacheKey(volKey: string) { return `dicom-${volKey}`; @@ -41,6 +42,7 @@ export default defineComponent({ const layersStore = useLayersStore(); const imageCacheStore = useImageCacheStore(); const viewStore = useViewStore(); + const segmentationStore = useSegmentationStore(); const { currentImageID } = useCurrentImage(); const volumes = computed(() => { @@ -73,6 +75,8 @@ export default defineComponent({ isLayer, layerable, layerLoading, + convertingToSegmentation: + segmentationStore.convertingLabelmaps.has(volumeKey), layerHandler: () => { if (!layerLoading && layerable) { if (isLayer) @@ -250,7 +254,10 @@ export default defineComponent({ justify="center" > @@ -264,8 +271,18 @@ export default defineComponent({ - -
+ +
+ + Adding segmentation… +
+
-import { usePolygonStore } from '@/src/store/tools/polygons'; -import LabelControls from '@/src/components/LabelControls.vue'; - -const activeToolStore = usePolygonStore(); - - - diff --git a/src/components/ProcessControls.vue b/src/components/ProcessControls.vue index dea798e2b..0e49c492e 100644 --- a/src/components/ProcessControls.vue +++ b/src/components/ProcessControls.vue @@ -18,9 +18,9 @@ - - diff --git a/src/components/RulerControls.vue b/src/components/RulerControls.vue deleted file mode 100644 index 088887267..000000000 --- a/src/components/RulerControls.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/components/SaveSegmentGroupDialog.vue b/src/components/SaveSegmentGroupDialog.vue deleted file mode 100644 index 654db9f60..000000000 --- a/src/components/SaveSegmentGroupDialog.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - diff --git a/src/components/SegmentEditor.vue b/src/components/SegmentEditor.vue deleted file mode 100644 index 1c3c54ae0..000000000 --- a/src/components/SegmentEditor.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/src/components/SegmentGroupControls.vue b/src/components/SegmentGroupControls.vue deleted file mode 100644 index 68b6cf723..000000000 --- a/src/components/SegmentGroupControls.vue +++ /dev/null @@ -1,423 +0,0 @@ - - - - - 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..9f15583f4 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -105,10 +105,10 @@ :axis="viewAxis" > @@ -162,7 +162,7 @@ @@ -179,8 +179,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 +197,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 +276,10 @@ onVTKEvent(currentImageData, 'onModified', () => { vtkView.value?.requestRender(); }); -const segmentations = computed(() => { +// One actor per segment, 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 +292,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..f04e595c4 --- /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 })) + ); + 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/components/ProcessWorkflow.vue b/src/segmentation/components/ProcessWorkflow.vue similarity index 81% rename from src/components/ProcessWorkflow.vue rename to src/segmentation/components/ProcessWorkflow.vue index 743437caa..88e148327 100644 --- a/src/components/ProcessWorkflow.vue +++ b/src/segmentation/components/ProcessWorkflow.vue @@ -28,11 +28,14 @@ divided density="compact" > - + + mdi-eye-outline Original - + mdi-eye-settings Processed @@ -58,11 +61,10 @@ 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..8168a1488 --- /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..38331c690 --- /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 other segments paint around it', 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(/goes around it/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..52754d1f3 --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts @@ -0,0 +1,406 @@ +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 and the fill goes around them. +// +// 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('fills around the voxels a locked neighbour holds', () => { + const locked = addMask('img-1', 'Locked'); + const unlocked = addMask('img-1', 'Unlocked'); + seedVoxel(locked, [2, 3, 0]); + seedVoxel(unlocked, [2, 3, 0]); + seedVoxel(unlocked, [3, 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(labelValueOf(unlocked)); + expect(maskValueAt(maskId, [2, 3, 0])).toBeFalsy(); + expect(maskValueAt(unlocked, [3, 3, 0])).toBe(0); + expect(maskValueAt(maskId, [3, 3, 0])).toBe(labelValueOf(maskId)); + }); + + it('shares the filled voxels with every neighbour while overlap is allowed', () => { + const locked = addMask('img-1', 'Locked'); + const unlocked = addMask('img-1', 'Unlocked'); + seedVoxel(locked, [2, 3, 0]); + seedVoxel(unlocked, [3, 3, 0]); + lockSegment(locked, true); + const maskId = addMask('img-1', 'Tumor'); + store().allowOverlap = true; + + rasterizeInto(maskId); + + expect(maskValueAt(locked, [2, 3, 0])).toBe(labelValueOf(locked)); + expect(maskValueAt(unlocked, [3, 3, 0])).toBe(labelValueOf(unlocked)); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + expect(maskValueAt(maskId, [3, 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 === 'First' || name === 'Second') voxels.scalars().fill(1); + // Held inside the polygon, so the fill goes around it. + if (name === 'Locked') { + seedVoxel(id, [1, 2, 0]); + 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, 0, 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..e7e7447aa --- /dev/null +++ b/src/segmentation/editing/rasterizePolygon.ts @@ -0,0 +1,208 @@ +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: the fill goes + // around it, 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 + * wherever `mayFill` agrees. Asked before the write, since a claim clears the + * voxel from the neighbours it takes it from. + */ +function createGridAccessor( + parent: vtkImageData, + mask: { image: vtkImageData; pixelData: TypedArray; extent: Extent3D }, + plane: { slice: number; axisIdx: 0 | 1 | 2 }, // i/j/k + mayFill: (ijk: Vector3) => boolean +): 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) && mayFill(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; + 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]) ?? true + ); + + 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..b8814a593 --- /dev/null +++ b/src/segmentation/io/import.ts @@ -0,0 +1,350 @@ +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[][] = []; + const cache = useImageCacheStore(); + 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); + if (!cache.imageById[imageID]) { + throw new Error('Labelmap image is no longer loaded'); + } + 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..264540693 --- /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, so each item can + // be reported with its specific missing-reference reason. + 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/masks/voxelAccess.ts b/src/segmentation/masks/voxelAccess.ts index f29e642cf..65f62bb45 100644 --- a/src/segmentation/masks/voxelAccess.ts +++ b/src/segmentation/masks/voxelAccess.ts @@ -35,6 +35,7 @@ export type VoxelAccessDeps = { segmentationOfMask: (maskId: string) => Segmentation | undefined; ensureLabelmapBinding: (maskId: string) => LabelmapBinding; maskLocked: (mask: SegmentMask) => boolean; + overlapAllowed: () => boolean; }; /** @@ -50,6 +51,7 @@ export function createVoxelAccess(deps: VoxelAccessDeps) { segmentationOfMask, ensureLabelmapBinding, maskLocked, + overlapAllowed, } = deps; function requireParentImage(maskId: string) { @@ -167,41 +169,52 @@ export function createVoxelAccess(deps: VoxelAccessDeps) { binding && boundScalars(binding.image, binding.extent); /** - * The masks of an image's other segments that `gesture` may take a voxel - * from, resolved once per run because the caller below runs per voxel. A - * locked segment is not editable, so an aimed gesture is not offered its mask - * at all. + * The masks of an image's other segments, split by what `gesture` does where + * one of them holds a voxel: take the voxel from it, or yield to it and leave + * the voxel unwritten. Resolved once per run because the caller below runs + * per voxel. A locked segment is not editable, so an aimed gesture yields to + * it rather than taking from it. */ function siblingMasks(maskId: string, gesture: VoxelGesture) { const segmentation = segmentationOfMask(maskId); - if (!segmentation) return []; - return listMasks(segmentation).flatMap((segment) => { - if (segment.id === maskId) return []; - if (gesture === 'aimed' && maskLocked(segment)) return []; - const bounded = boundedMask(segment.representations.labelmap); - return bounded ? [bounded] : []; - }); + const others = + segmentation && !(gesture === 'aimed' && overlapAllowed()) + ? listMasks(segmentation).filter((segment) => segment.id !== maskId) + : []; + const takes = (segment: SegmentMask) => + gesture === 'aimed' && !maskLocked(segment); + const bounded = (segments: SegmentMask[]) => + segments.flatMap((segment) => { + const mask = boundedMask(segment.representations.labelmap); + return mask ? [mask] : []; + }); + return { + takeFrom: bounded(others.filter(takes)), + yieldTo: bounded(others.filter((segment) => !takes(segment))), + }; } /** * Whether the voxel at PARENT indices i, j, k is this segment's to write, - * taking it from the neighbours that have to yield it. Absent when no other - * segment reaches `within`, the box the caller is about to walk: every voxel - * in it is then uncontested and the question need not be asked per voxel. + * clearing it from the neighbours that have to give it up. Asked before the + * write: a voxel a neighbour it yields to holds is refused and taken from + * nobody. + * Absent when no other segment reaches `within`, the box the caller is about + * to walk: every voxel in it is then uncontested. * * `gesture` is the whole of the policy, so see {@link VoxelGesture}. An aimed * operation must call finish in a finally block after its last voxel write. */ function voxelClaim(maskId: string, gesture: VoxelGesture, within: Extent3D) { - const masks = siblingMasks(maskId, gesture); - if (gesture === 'aimed') return masksClearing(masks, within); - const held = masksHolding(masks, within); - return ( - held && { - claim: (i: number, j: number, k: number) => !held(i, j, k), - finish: () => undefined, - } - ); + const { takeFrom, yieldTo } = siblingMasks(maskId, gesture); + const held = masksHolding(yieldTo, within); + const clearing = masksClearing(takeFrom, within); + if (!held && !clearing) return undefined; + return { + claim: (i: number, j: number, k: number) => + !held?.(i, j, k) && (clearing?.claim(i, j, k) ?? true), + finish: () => clearing?.finish(), + }; } return { diff --git a/src/segmentation/model.ts b/src/segmentation/model.ts index 76f62d905..c6bb34ee7 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}`; @@ -123,5 +134,9 @@ export function listMasks(segmentation: Segmentation) { return segmentation.order.map((id) => segmentation.masks[id]); } -/** Aimed writes clear unlocked neighbors; sweeps only grow into unclaimed voxels. */ +/** + * Aimed writes take voxels from unlocked neighbors and go around locked ones, + * or leave every neighbor alone while overlap is allowed. Sweeps only grow into + * unclaimed voxels. + */ export type VoxelGesture = 'aimed' | 'sweep'; diff --git a/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue b/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue new file mode 100644 index 000000000..a6ac29cc5 --- /dev/null +++ b/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue @@ -0,0 +1,255 @@ + + + diff --git a/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts b/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts index d9d3d39fe..fcaa9b631 100644 --- a/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts +++ b/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest'; import { - segmentCoincidentOffset, + SEGMENT_COINCIDENT_OFFSET, + segmentDrawsOnSlice, sliceWithinExtent, } from '@/src/segmentation/rendering/display'; import { emptyExtent, type Extent3D } from '@/src/segmentation/geometry'; // --------------------------------------------------------------------------- -// The two view-layer rules a mask per segment needs. +// The view-layer rules a mask per segment needs. // // `sliceWithinExtent` answers whether a segment's actor has anything to draw on // the slice being viewed. A bounded mask covers only part of the volume, and @@ -16,10 +17,13 @@ import { emptyExtent, type Extent3D } from '@/src/segmentation/geometry'; // The slice and the extent are both in the PARENT image's index space, on the // index axis the view's LPS axis maps to. // -// `segmentCoincidentOffset` gives each segment its own coincident-topology -// polygon offset, by its back-to-front stack index. Overlap is -// representable, so segments sharing one offset would z-fight. -// Greater stack indices draw in front. Registry order is mapped in reverse. +// `segmentDrawsOnSlice` adds the segment's own visibility: a hidden segment's +// actor is taken out of the scene rather than drawn at zero alpha. +// +// `SEGMENT_COINCIDENT_OFFSET` is the coincident-topology polygon offset every +// segment draws at, which lifts it off the coplanar base image. It carries no +// per-segment term: the actors are translucent, so the renderer blends the +// overlap rather than stacking it, and a per-segment offset would do nothing. // --------------------------------------------------------------------------- const EXTENT: Extent3D = [1, 2, 0, 3, 2, 5]; @@ -54,34 +58,38 @@ describe('sliceWithinExtent', () => { }); }); -describe('segmentCoincidentOffset', () => { - it('puts the first segment in front of the base image', () => { - const [factor, units] = segmentCoincidentOffset(0); +describe('segmentDrawsOnSlice', () => { + it('draws a visible segment on a slice inside its extent', () => { + expect(segmentDrawsOnSlice({ visible: true }, EXTENT, 2, 3)).toBe(true); + }); - expect(factor).toBeLessThan(0); - expect(units).toBeLessThan(0); + it('does not draw a hidden segment, even inside its extent', () => { + expect(segmentDrawsOnSlice({ visible: false }, EXTENT, 2, 3)).toBe(false); }); - it('puts a greater stack index in front of a smaller one', () => { - const [earlierFactor, earlierUnits] = segmentCoincidentOffset(0); - const [laterFactor, laterUnits] = segmentCoincidentOffset(1); + it('does not draw a visible segment off its extent', () => { + expect(segmentDrawsOnSlice({ visible: true }, EXTENT, 2, 6)).toBe(false); + }); - expect(laterUnits).toBeLessThan(earlierUnits); - expect(laterFactor).toBeLessThanOrEqual(earlierFactor); + it('does not draw a segment that is gone or has no extent', () => { + expect(segmentDrawsOnSlice(undefined, EXTENT, 2, 3)).toBe(false); + expect(segmentDrawsOnSlice({ visible: true }, undefined, 2, 3)).toBe(false); + }); +}); + +describe('SEGMENT_COINCIDENT_OFFSET', () => { + it('puts a segment in front of the base image', () => { + const [factor, units] = SEGMENT_COINCIDENT_OFFSET; + + expect(factor).toBeLessThan(0); + expect(units).toBeLessThan(0); }); - it('keeps that order all the way down a long list', () => { - const offsets = Array.from({ length: 64 }, (_, index) => - segmentCoincidentOffset(index) - ); - - expect( - offsets.every( - ([factor, units]) => Number.isFinite(factor) && Number.isFinite(units) - ) - ).toBe(true); - offsets.slice(1).forEach(([, units], index) => { - expect(units).toBeLessThan(offsets[index][1]); - }); + it('is one offset, not a per-segment one', () => { + expect(SEGMENT_COINCIDENT_OFFSET).toEqual([-4, -4]); + // The same number twice, and nothing in either entry that a segment's + // place in the list could reach. + const [factor, units] = SEGMENT_COINCIDENT_OFFSET; + expect(factor).toBe(units); }); }); diff --git a/src/segmentation/rendering/display.ts b/src/segmentation/rendering/display.ts index 484cae0e3..a43021f96 100644 --- a/src/segmentation/rendering/display.ts +++ b/src/segmentation/rendering/display.ts @@ -18,6 +18,19 @@ export function sliceWithinExtent( return slice >= extent[axisIndex * 2] && slice <= extent[axisIndex * 2 + 1]; } +/** + * Whether a segment's actor is drawn at all. A hidden segment has zero fill and + * outline, but a visible actor is still traversed and drawn on every render, + * which adds up across a scene of many segments. + */ +export const segmentDrawsOnSlice = ( + segment: Pick | undefined, + extent: Extent3D | undefined, + axisIndex: number, + slice: number +) => + !!segment?.visible && !!extent && sliceWithinExtent(extent, axisIndex, slice); + const SEGMENT_OFFSET_FACTOR = -4; /** @@ -30,17 +43,16 @@ const SEGMENT_OFFSET_FACTOR = -4; export const SEGMENT_ACTOR_OPACITY = 0.9999; /** - * A mask's coincident-topology polygon offset, by back-to-front stack index. - * Overlapping segments need distinct offsets to avoid z-fighting. Greater - * stack indices sit closer to the viewer; the registry maps its first entry - * to the greatest index. + * The coincident-topology polygon offset every mask draws at, which lifts it + * off the coplanar base image. It is the same for all of them: a segment actor + * is translucent, so vtk.js draws it in the order-independent translucent pass + * with depth writes off, and a per-segment offset would change nothing about + * how two segments blend where they overlap. */ -export function segmentCoincidentOffset(stackIndex: number) { - return [SEGMENT_OFFSET_FACTOR, SEGMENT_OFFSET_FACTOR - stackIndex] as [ - number, - number, - ]; -} +export const SEGMENT_COINCIDENT_OFFSET: [number, number] = [ + SEGMENT_OFFSET_FACTOR, + SEGMENT_OFFSET_FACTOR, +]; /** * Fill alpha in 0..1 for the slice representation's piecewise function: the diff --git a/src/segmentation/segmentRegistry.ts b/src/segmentation/segmentRegistry.ts index 14ef5b2c6..816b9b0e8 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]; @@ -218,7 +275,7 @@ export const createSegmentRegistry = ({ }); [...configEntries.entries()] - .filter(([name]) => !(name in next)) + .filter(([name]) => !Object.hasOwn(next, name)) .forEach(([name, { id }]) => { configEntries.delete(name); // Content keeps the last configured appearance as session state. @@ -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..acbd61be9 --- /dev/null +++ b/src/segmentation/store.ts @@ -0,0 +1,672 @@ +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; + + // Editor state, not document state: it is never serialized. + const allowOverlap = ref(false); + + const { maskVoxels, findMaskVoxels, voxelClaim } = createVoxelAccess({ + imageCacheStore, + findMask, + getMask, + segmentationOfMask, + ensureLabelmapBinding, + maskLocked, + overlapAllowed: () => allowOverlap.value, + }); + + /** 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 }]; + }); + } + + /** + * The masks an image draws, in `order`. One actor each; they are translucent, + * so the renderer blends them rather than stacking them by this order. + */ + function maskLayersForImage(parentImageId: string) { + return imageMasks(parentImageId).flatMap((segment) => { + return segment.representations.labelmap ? [{ maskId: segment.id }] : []; + }); + } + + 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, + allowOverlap, + 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__/datasets-layers.spec.ts b/src/store/__tests__/datasets-layers.spec.ts index 53f2736e5..7fac26bf0 100644 --- a/src/store/__tests__/datasets-layers.spec.ts +++ b/src/store/__tests__/datasets-layers.spec.ts @@ -80,6 +80,35 @@ describe('useLayersStore.addLayer return contract', () => { 'no overlap in physical space' ); }); + + it('settles and removes the provisional layer when the source is absent', async () => { + seatImage('parent', 0); + const store = useLayersStore(); + + const id = await store.addLayer('parent', 'missing-source'); + + expect(id).toBeUndefined(); + expect(store.getLayers('parent')).toHaveLength(0); + expect(useMessageStore().messages[0].options.details).toContain( + 'Image did not load' + ); + }); + it('caches no layer image when the layer is deleted while it resamples', async () => { + seatOverlappingPair(); + const store = useLayersStore(); + ensureSameSpace.mockImplementation( + async (_parent: unknown, source: unknown) => { + store.deleteLayer('parent', 'source'); + return source; + } + ); + + const id = await store.addLayer('parent', 'source'); + + expect(id).toBeUndefined(); + expect(cached('parent::source')).toBe(false); + expect(useMessageStore().messages).toHaveLength(0); + }); }); describe('useLayersStore.remove', () => { 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-layers.ts b/src/store/datasets-layers.ts index d2edb7783..f87ba8405 100644 --- a/src/store/datasets-layers.ts +++ b/src/store/datasets-layers.ts @@ -58,6 +58,9 @@ export const useLayersStore = defineStore('layer', () => { } const image = await ensureSameSpace(parentImage, sourceImage); + // Deleted while resampling: caching the image now would leave it unowned. + if (!parentToLayers[parent]?.some((layer) => layer.id === id)) + return undefined; const name = imageCacheStore.getImageMetadata(source)?.name ?? NO_NAME; imageCacheStore.addVTKImageData(image, name, { id }); 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..62672b3ec 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,25 @@ 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, +} 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 +42,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 +49,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 +60,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 +118,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. + * 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 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 - */ - 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 +152,116 @@ 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(); + const { extent } = voxels.binding()!; + 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 from + // an unlocked neighbour. + const claimVoxel = erasing + ? undefined + : 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]; - return minThreshold <= pixValue && pixValue <= maxThreshold; - }; + // 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: the callback below runs 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 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; + const shouldPaint = (offset: number, point: number[]) => { + // Erase clears the active segment only. + if (erasing && maskData[offset] !== labelValue) return false; - this.$paint.paintLabelmap( - labelmap, - axisIndex, - lastIndexPoint, - prevIndexPoint, - shouldPaint - ); + const pixValue = underlyingImagePixels[parentOffset(point)]; + if (!(minThreshold <= pixValue && pixValue <= maxThreshold)) return false; + + // Asked last: the claim clears the voxel from neighbours, so it runs + // only for a voxel that is about to be written. + return claimVoxel?.claim(point[0], point[1], point[2]) ?? true; + }; + + 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, + }); + } finally { + claimVoxel?.finish(); } } @@ -294,42 +275,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 +328,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 +401,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 +415,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 +422,6 @@ export const usePaintToolStore = defineStore('paint', () => { activeMode, activePaintMode, processControlsOpen, - activeSegmentGroupID, - activeSegment, brushSize, strokePoints, isActive, @@ -499,13 +438,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..674b05a9e 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,45 @@ 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'; +import { useImageCacheStore } from '@/src/store/image-cache'; // 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 +57,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 +94,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 +105,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 +114,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 +133,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 +163,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 +183,70 @@ 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]; - }) - ); - + const imageCache = useImageCacheStore(); serialized?.tools + .filter(({ imageID, segmentId }) => { + const mappedImageId = dataIDMap[imageID]; + if (!mappedImageId || !imageCache.imageById[mappedImageId]) + return false; + 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 +254,8 @@ export const useAnnotationTool = < addTool, removeTool, updateTool, + resolveToolType, + placeTool, jumpToTool, serializeTools, deserializeTools, @@ -234,7 +265,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 0b2e24c52..008c54b82 100644 Binary files a/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png and b/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png differ diff --git a/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png b/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png index 45adf1d1e..7ab9b5494 100644 Binary files a/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png and b/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png differ diff --git a/tests/fixtures/label-outline/index.html b/tests/fixtures/label-outline/index.html new file mode 100644 index 000000000..2fd264d30 --- /dev/null +++ b/tests/fixtures/label-outline/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/fixtures/label-outline/scene.ts b/tests/fixtures/label-outline/scene.ts new file mode 100644 index 000000000..7eb6fb5a8 --- /dev/null +++ b/tests/fixtures/label-outline/scene.ts @@ -0,0 +1,141 @@ +import '@kitware/vtk.js/Rendering/Profiles/Volume'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { + allocateMask, + reframeMaskScalars, + setMaskScalars, +} from '@/src/segmentation/masks/storage'; +import { maskScalars } from '@/src/segmentation/model'; +import type { Extent3D } from '@/src/segmentation/geometry'; +import { segmentRenderMask } from '@/src/segmentation/rendering/renderMask'; +import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow'; +import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; +import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import { + SEGMENT_ACTOR_OPACITY, + segmentOutlineTables, +} from '@/src/segmentation/rendering/display'; + +const renderer = vtkRenderer.newInstance(); +const renderWindow = vtkRenderWindow.newInstance(); +const view = vtkOpenGLRenderWindow.newInstance(); +renderWindow.addRenderer(renderer); +renderWindow.addView(view); +const container = document.createElement('div'); +container.style.cssText = 'width:200px;height:200px'; +document.body.appendChild(container); +view.setContainer(container); +view.setSize(200, 200); +const params = new URLSearchParams(location.search); +const axis = Number(params.get('axis') ?? 2); +const scanEdge = params.has('scanEdge'); +const parent = vtkImageData.newInstance(); +const dimensions: [number, number, number] = [10, 10, 10]; +dimensions[axis] = 1; +// Truncate only the high face of the first in-plane axis. +const edgeAxis = axis === 0 ? 1 : 0; +if (scanEdge) dimensions[edgeAxis] = 9; +parent.setDimensions(dimensions); +parent.setOrigin([12, -17, 23]); +const extent: Extent3D = [1, 8, 1, 8, 1, 8]; +extent[axis * 2] = 0; +extent[axis * 2 + 1] = 0; +const source = allocateMask(parent, extent); +maskScalars(source).fill(1); +source.modified(); +const mask = params.has('fullGrid') + ? allocateMask(parent, parent.getExtent() as Extent3D) + : segmentRenderMask(source, parent, extent, { axis: axis, index: 0 })!; +if (params.has('fullGrid')) { + setMaskScalars( + mask, + reframeMaskScalars( + maskScalars(source), + extent, + parent.getExtent() as Extent3D + ) + ); +} +const mapper = vtkImageMapper.newInstance(); +mapper.setInputData(mask); +mapper.setSlicingMode(axis); +mapper.setSlice(0); +const actor = vtkImageSlice.newInstance(); +actor.setMapper(mapper); +const property = actor.getProperty(); +property.setInterpolationTypeToNearest(); +property.setOpacity(SEGMENT_ACTOR_OPACITY); +property.setUseLookupTableScalarRange(true); +property.setUseLabelOutline(true); +property.setLabelOutlineThickness([3]); +property.setLabelOutlineOpacity([1]); +const colors = vtkColorTransferFunction.newInstance(); +colors.addRGBPoint(0, 0, 0, 0); +colors.addRGBPoint(1, 1, 0, 0); +colors.addRGBPoint(2, 0, 0, 0); +const opacity = vtkPiecewiseFunction.newInstance(); +opacity.addPoint(0, 0); +opacity.addPoint(1, 0.2); +opacity.addPoint(2, 0); +property.setRGBTransferFunction(0, colors); +property.setScalarOpacity(0, opacity); +renderer.addActor(actor); +const camera = renderer.getActiveCamera(); +camera.setParallelProjection(true); +const focal: [number, number, number] = [3.5, 3.5, 3.5]; +focal[axis] = 0; +const worldFocal = source.indexToWorld(focal); +const position = [...worldFocal] as [number, number, number]; +position[axis] += 10; +camera.setPosition(...position); +camera.setFocalPoint(worldFocal[0], worldFocal[1], worldFocal[2]); +if (axis === 1) camera.setViewUp(0, 0, 1); +camera.setParallelScale(5); +renderer.resetCameraClippingRange(); +// Keep the same actor while changing tables, as Reveal does. +function renderOutline(thickness = 3, outlineOpacity = 1) { + const tables = segmentOutlineTables( + [{ value: 1, name: 'Mask', visible: true, color: [255, 0, 0, 255] }], + thickness, + outlineOpacity + ); + property.setLabelOutlineThickness(tables.thicknesses); + property.setLabelOutlineOpacity(tables.opacities); + renderWindow.render(); + const gl = view.get3DContext({}); + if (!gl) throw new Error('WebGL is required for the outline regression'); + const pixels = new Uint8Array(200 * 200 * 4); + gl.readPixels(0, 0, 200, 200, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const red = (x: number, y: number) => pixels[(y * 200 + x) * 4]; + return { + left: red(21, 100), + right: red(178, 100), + bottom: red(100, 21), + top: red(100, 178), + innerEdge: red(24, 100), + center: red(100, 100), + outside: red(19, 100), + }; +} + +function editMask(value: number) { + maskScalars(source).fill(value); + source.modified(); + segmentRenderMask(source, parent, extent, { axis: axis, index: 0 }); + return renderOutline(); +} + +declare global { + interface Window { + renderOutline: typeof renderOutline; + editMask: typeof editMask; + outlineResult: ReturnType; + } +} +window.renderOutline = renderOutline; +window.editMask = editMask; +window.outlineResult = renderOutline(); diff --git a/tests/fixtures/label-outline/server.mjs b/tests/fixtures/label-outline/server.mjs new file mode 100644 index 000000000..8435bf9f9 --- /dev/null +++ b/tests/fixtures/label-outline/server.mjs @@ -0,0 +1,26 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +// Serve the isolated WebGL scene without the application's HTML entry plugin. +export async function startOutlineFixture() { + const cacheDir = await mkdtemp(resolve(tmpdir(), 'label-outline-')); + const root = fileURLToPath(new URL('../../../', import.meta.url)); + const server = await createServer({ + configFile: false, + root: fileURLToPath(new URL('.', import.meta.url)), + cacheDir, + resolve: { alias: { '@': root } }, + server: { host: '127.0.0.1', fs: { allow: [root] } }, + }); + await server.listen(); + return { + url: server.resolvedUrls.local[0], + async close() { + await server.close(); + await rm(cacheDir, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/pageobjects/volview.page.ts b/tests/pageobjects/volview.page.ts index 699fdf60d..b4c048dbc 100644 --- a/tests/pageobjects/volview.page.ts +++ b/tests/pageobjects/volview.page.ts @@ -97,7 +97,7 @@ class VolViewPage extends Page { } async selectTool(icon: string) { - const button = $(`button span i[class~=${icon}]`); + const button = $(`button.tool-btn i[class~=${icon}]`); await button.waitForClickable(); await button.click(); } @@ -153,11 +153,12 @@ class VolViewPage extends Page { return (classes ?? '').includes('v-btn--active'); } - async paintStrokeOnView(view: ChainablePromiseElement) { + /** A square loop starting `offsetX` pixels right of the view's center. */ + async paintStrokeOnView(view: ChainablePromiseElement, offsetX = 0) { const canvas = await view.$('canvas'); const location = await canvas.getLocation(); const size = await canvas.getSize(); - const centerX = Math.round(location.x + size.width / 2); + const centerX = Math.round(location.x + size.width / 2) + offsetX; const centerY = Math.round(location.y + size.height / 2); await browser @@ -214,18 +215,10 @@ class VolViewPage extends Page { return $('button[data-testid="module-tab-Annotations"]'); } - get newSegmentGroupButton() { - return $('button*=New Group'); - } - get activeDialog() { return $('div[role="dialog"]'); } - get activeDialogInput() { - return this.activeDialog.$('input[placeholder="Unnamed Segment Group"]'); - } - get saveSessionFilenameInput() { return $('#session-state-filename'); } @@ -234,32 +227,28 @@ class VolViewPage extends Page { return $('span[data-testid="save-session-confirm-button"]'); } - get segmentGroupsTab() { - return $('button.v-tab*=Segment Groups'); + get saveSegmentsButtons() { + return $$('button[data-testid="save-segments-button"]'); } - get segmentGroupSaveButtons() { - return $$('button[data-testid="segment-group-save-button"]'); + get segmentList() { + return $('[data-testid="segment-list"]'); } - get segmentGroupList() { - return $('.segment-group-list'); - } - - get saveSegmentGroupFilenameInput() { + get saveSegmentsFilenameInput() { return this.activeDialog.$('#filename'); } - get saveSegmentGroupConfirmButton() { + get saveSegmentsConfirmButton() { return this.activeDialog.$('button=Save'); } - async clickFirstSegmentGroupSaveButton() { + async clickSaveSegmentsButton() { await browser.waitUntil(async () => { - const buttons = await this.segmentGroupSaveButtons; + const buttons = await this.saveSegmentsButtons; return (await buttons.length) >= 1; }); - const buttons = await this.segmentGroupSaveButtons; + const buttons = await this.saveSegmentsButtons; await buttons[0].scrollIntoView(); await buttons[0].waitForClickable(); await buttons[0].click(); @@ -288,27 +277,10 @@ class VolViewPage extends Page { return fileName; } - async createSegmentGroup(name: string) { - const annotationsTab = await this.annotationsModuleTab; - await annotationsTab.click(); - - const newGroup = await this.newSegmentGroupButton; - await newGroup.waitForClickable(); - await newGroup.click(); - - const input = await this.activeDialogInput; - await input.waitForDisplayed(); - await setValueVueInput(input, name); - await browser.keys([Key.Enter]); - } - - get editLabelButtons() { - return $$('button[data-testid="edit-label-button"]'); - } - - get labelStrokeWidthInput() { - // there should only be one on the screen at any given time - return $('.label-stroke-width-input').$('input'); + get segmentStrokeWidthSlider() { + return $( + '//label[normalize-space()="Stroke Width"]/ancestor::div[contains(@class, "v-slider")][1]//*[@role="slider"]' + ); } get editLabelModalDoneButton() { diff --git a/tests/specs/annotation-mints-segment.e2e.ts b/tests/specs/annotation-mints-segment.e2e.ts new file mode 100644 index 000000000..7746e358e --- /dev/null +++ b/tests/specs/annotation-mints-segment.e2e.ts @@ -0,0 +1,66 @@ +import { type ChainablePromiseElement } from 'webdriverio'; +import AppPage from '../pageobjects/volview.page'; +import { clickAt, drawSquare, setupTest } from './annotationTestUtils'; +import { + openAnnotationSegments, + segmentNames, + segmentRow, +} from './segmentationTestUtils'; + +const hexOf = async (element: ChainablePromiseElement, property: string) => { + const { parsed } = await element.getCSSProperty(property); + return parsed.hex; +}; + +const segmentDotHex = async (name: string) => + hexOf((await segmentRow(name)).$('.color-dot'), 'background-color'); + +// A row renders before its title does, so a name-less row is not yet a +// segment the caller can read. +const waitForSegmentCount = (expected: number, timeoutMsg: string) => + browser.waitUntil( + async () => { + const names = await segmentNames(); + return names.length === expected && names.every((name) => name.length); + }, + { timeoutMsg } + ); + +describe('An annotation placed against no segment', () => { + it('mints one segment the rectangle and the polygon then share', async () => { + const { axialView, centerX, centerY } = await setupTest(); + await openAnnotationSegments(); + expect(await segmentNames()).toEqual([]); + + // The first corner is the gesture that mints, so the rubber band is drawn + // in the segment's color rather than changing color once it lands. + await AppPage.activateRectangle(); + await clickAt(centerX - 60, centerY - 60); + + await openAnnotationSegments(); + await waitForSegmentCount( + 1, + 'Starting a rectangle with nothing selected should mint a segment' + ); + const [name] = await segmentNames(); + const segmentHex = await segmentDotHex(name); + const whilePlacing = await hexOf(axialView.$('svg rect'), 'stroke'); + expect(whilePlacing).toBe(segmentHex); + + await clickAt(centerX + 60, centerY + 60); + expect(await hexOf(axialView.$('svg rect'), 'stroke')).toBe(whilePlacing); + + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY + 80, 40); + await axialView.$('svg polyline').waitForExist({ + timeoutMsg: 'Expected the placed polygon to render', + }); + expect(await hexOf(axialView.$('svg polyline'), 'stroke')).toBe(segmentHex); + + await openAnnotationSegments(); + await waitForSegmentCount( + 1, + 'The polygon should join the minted segment, not mint a second one' + ); + }); +}); diff --git a/tests/specs/annotationTestUtils.ts b/tests/specs/annotationTestUtils.ts index 66c3fc3d5..143652b0d 100644 --- a/tests/specs/annotationTestUtils.ts +++ b/tests/specs/annotationTestUtils.ts @@ -14,6 +14,48 @@ export const clickAt = (x: number, y: number) => export const rightClickAt = (x: number, y: number) => pointerAt(x, y).down({ button: 2 }).up({ button: 2 }).perform(); +// One input source held across action chains, so a press can land exactly where +// an earlier chain left the pointer. Chains that keep it perform without +// releasing actions, as releasing resets the pointer to the viewport origin. +const HOVERING_MOUSE = 'hovering-mouse'; +const hoveringMouse = () => browser.action('pointer', { id: HOVERING_MOUSE }); + +// A move with a duration is interpolated into a stream of pointer moves. A +// zero duration dispatches exactly one, which is what teleportTo relies on. +const INSTANT = 0; +const NUDGE_PX = 2; + +// Two moves in one chain, so the one landing on (x, y) is never the first move +// after an idle period, which vtk.js reports as StartMouseMove and the widget +// manager ignores. The pick therefore runs at (x, y). +export const nudgeTo = (x: number, y: number) => + hoveringMouse() + .move({ + duration: INSTANT, + x: Math.round(x) + NUDGE_PX, + y: Math.round(y) + NUDGE_PX, + }) + .move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) }) + .perform(true); + +// vtk.js reports the first pointer move after ~200ms of stillness as +// StartMouseMove, which the widget manager does not subscribe to. A single move +// after that idle therefore relocates the pointer while leaving the widget +// manager's pick standing at the old position. +const IDLE_MS = 400; + +export const teleportTo = async (x: number, y: number) => { + await browser.pause(IDLE_MS); + await hoveringMouse() + .move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) }) + .perform(true); +}; + +export const pressAtPointer = () => hoveringMouse().down().up().perform(true); + +export const rightPressAtPointer = () => + hoveringMouse().down({ button: 2 }).up({ button: 2 }).perform(true); + /** * Loads the minimal DICOM and returns the axial view with the center of its * canvas in page coordinates. @@ -36,6 +78,14 @@ export const setupTest = async () => { }; }; +export const drawSquare = async (cx: number, cy: number, half: number) => { + await clickAt(cx - half, cy - half); + await clickAt(cx + half, cy - half); + await clickAt(cx + half, cy + half); + await clickAt(cx - half, cy + half); + await clickAt(cx - half, cy - half); // close +}; + // Handles of placed annotations export const getCircleCount = async (axialView: ChainablePromiseElement) => { const circles = await axialView.$$('svg circle'); diff --git a/tests/specs/annotations-sidebar.e2e.ts b/tests/specs/annotations-sidebar.e2e.ts new file mode 100644 index 000000000..b6313795a --- /dev/null +++ b/tests/specs/annotations-sidebar.e2e.ts @@ -0,0 +1,379 @@ +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +import { PROSTATEX_DATASET } from '../datasets'; +import { openUrls } from './utils'; +import { + clickAt, + nudgeTo, + pressAtPointer, + rightClickAt, + setupTest, + waitForCircleCount, +} from './annotationTestUtils'; +import { + addSegment, + openAnnotationSegments, + lockSegment, + segmentRow, + openSegmentShapes, + renameSegment, + revealSegment, + segmentListTop, + segmentNames, + selectSegment, + selectedSegmentName, + shapeRowTexts, + waitForNamedSegments, +} from './segmentationTestUtils'; + +const placeRectangle = async () => { + const test = await setupTest(); + await volViewPage.activateRectangle(); + await clickAt(test.centerX - 60, test.centerY - 60); + await clickAt(test.centerX + 60, test.centerY + 60); + await volViewPage.selectTool('mdi-cursor-default'); + await waitForCircleCount( + test.axialView, + 2, + 'Placed rectangle should render both handles' + ); + return test; +}; + +// The sidebar sections are stacked rather than tabbed, so the Segments list is +// reachable whatever tool is active. +const DRAWING_TOOLS = [ + 'mdi-vector-square', + 'mdi-pentagon-outline', + 'mdi-ruler', + 'mdi-brush', +]; + +describe('Annotations sidebar', () => { + it('keeps the Segments list in place and selected across tool switches', async () => { + await setupTest(); + + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + + await openAnnotationSegments(); + await waitForNamedSegments(); + await addSegment(); + await renameSegment('Segment 2', 'Lesion'); + await selectSegment('Lesion'); + + const top = await segmentListTop(); + expect(await selectedSegmentName()).toEqual('Lesion'); + + for (const icon of DRAWING_TOOLS) { + await volViewPage.selectTool(icon); + await $('[data-testid="segment-list"]').waitForDisplayed(); + expect(await segmentNames()).toEqual(['Segment 1', 'Lesion']); + expect(await selectedSegmentName()).toEqual('Lesion'); + expect(await segmentListTop()).toEqual(top); + } + }); + + it('selects one segment on ruler activation and uses it for the placement', async () => { + const { centerX, centerY } = await setupTest(); + + await volViewPage.selectTool('mdi-ruler'); + await openAnnotationSegments(); + expect(await segmentNames()).toEqual(['Segment 1']); + expect(await selectedSegmentName()).toEqual('Segment 1'); + + // vtk.js ignores the first pointer move after an idle period, and the + // sidebar work above is one, so each end is nudged onto and then pressed. + await nudgeTo(centerX - 40, centerY); + await pressAtPointer(); + await nudgeTo(centerX + 40, centerY); + await pressAtPointer(); + + await waitForNamedSegments(); + expect(await segmentNames()).toEqual(['Segment 1']); + + await openSegmentShapes(); + const shapes = await shapeRowTexts(); + expect(shapes).toHaveLength(1); + expect(shapes[0]).toMatch(/mm/); + }); + + it('hides rendered annotations with their segment and preserves individually hidden shapes', async () => { + const { axialView, centerX, centerY } = await placeRectangle(); + await openAnnotationSegments(); + const row = await segmentRow('Segment 1'); + await row.$('button:has(i.mdi-eye)').click(); + await waitForCircleCount( + axialView, + 0, + 'Hiding the segment should remove its rectangle widget' + ); + + // Removing the SVG alone would leave VTK handles pickable. The old handle + // must not open its annotation menu after the segment is hidden. + await rightClickAt(centerX - 60, centerY - 60); + const menuTitles = await $$('.v-overlay--active .v-list-item-title').map( + (title) => title.getText() + ); + expect(menuTitles).not.toContain('Delete Annotation'); + await row.$('button:has(i.mdi-eye-off)').click(); + await waitForCircleCount( + axialView, + 2, + 'Showing the segment should restore its rectangle' + ); + + await openSegmentShapes(); + await $( + '[data-testid="segment-shape-row"] button i[class~="mdi-eye"]' + ).click(); + await waitForCircleCount( + axialView, + 0, + 'The child visibility control should hide the rectangle' + ); + await $('[data-testid="toggle-segments-visible-button"]').click(); + await $('[data-testid="toggle-segments-visible-button"]').click(); + await waitForCircleCount( + axialView, + 0, + 'Showing every segment must preserve a hidden child' + ); + await $( + '[data-testid="segment-shape-row"] button i[class~="mdi-eye-off"]' + ).click(); + await waitForCircleCount( + axialView, + 2, + 'Showing the child should restore the same rectangle' + ); + }); + + it('hides the selection outline of a selected annotation with its segment', async () => { + const { axialView } = await placeRectangle(); + + // BoundingRectangle.vue draws this around the selected annotation. + const outline = () => axialView.$('svg rect[stroke="lightgray"]'); + expect(await outline().isExisting()).toBe(false); + + await openAnnotationSegments(); + const row = await segmentRow('Segment 1'); + await openSegmentShapes(); + await $('[data-testid="segment-shape-row"] .v-checkbox-btn').click(); + await outline().waitForExist({ + timeoutMsg: 'Selecting the rectangle should draw its selection outline', + }); + + await row.$('button:has(i.mdi-eye)').click(); + await waitForCircleCount( + axialView, + 0, + 'Hiding the segment should remove its selected rectangle' + ); + await outline().waitForExist({ + reverse: true, + timeoutMsg: 'Hiding the segment should remove the selection outline', + }); + + await row.$('button:has(i.mdi-eye-off)').click(); + await waitForCircleCount( + axialView, + 2, + 'Showing the segment should restore its rectangle' + ); + await outline().waitForExist({ + timeoutMsg: 'Showing the segment should restore the selection outline', + }); + }); + + it('explains disabled controls and prevents the locked color button from opening the editor', async () => { + await setupTest(); + await volViewPage.selectTool('mdi-ruler'); + await openAnnotationSegments(); + await lockSegment('Segment 1'); + const row = await segmentRow('Segment 1'); + const controls = [ + { + button: row.$('[data-testid="segment-color-button"]'), + reason: 'Unlock this segment to change its color', + }, + { + button: row.$('[data-testid="edit-segment-button"]'), + reason: 'Unlock this segment to edit it', + }, + { + button: row.$('[data-testid="delete-segment-button"]'), + reason: 'Unlock this segment to delete it', + }, + { + button: row.$('[data-testid="reveal-segment-button"]'), + reason: 'This segment has nothing on this image', + }, + { + button: $('[data-testid="save-segments-button"]'), + reason: 'Nothing is painted on this image yet', + }, + ]; + for (const { button, reason } of controls) { + expect(await button.isEnabled()).toBe(false); + // Vuetify disables pointer events on the button, so hover its wrapper. + await button.$('..').moveTo(); + await expect( + $('.v-tooltip.v-overlay--active .v-overlay__content') + ).toHaveText(reason); + } + const dot = row.$('[data-testid="segment-color-button"]'); + const location = await dot.getLocation(); + const size = await dot.getSize(); + await clickAt(location.x + size.width / 2, location.y + size.height / 2); + expect(await $('div[role="dialog"]').isDisplayed()).toBe(false); + expect(await segmentNames()).toEqual(['Segment 1']); + + await row.$('button i[class~="mdi-lock"]').click(); + await dot.click(); + await $('div[role="dialog"]').waitForDisplayed(); + await volViewPage.editLabelModalDoneButton.click(); + await $('div[role="dialog"]').waitForDisplayed({ reverse: true }); + }); + + it('cancels segment edits with Escape while keeping tool shortcuts isolated', async () => { + await setupTest(); + await volViewPage.selectTool('mdi-ruler'); + await openAnnotationSegments(); + const row = await segmentRow('Segment 1'); + const edit = row.$('[data-testid="edit-segment-button"]'); + const dialog = () => $('div[role="dialog"]'); + const name = () => dialog().$('.v-text-field input'); + const paint = $('button.tool-btn:has(i.mdi-brush)'); + + await edit.execute((element) => element.focus()); + await browser.keys('Enter'); + await dialog().waitForDisplayed(); + await setValueVueInput(name(), 'Discarded draft'); + await volViewPage.editLabelModalDoneButton.execute((element) => + element.focus() + ); + await browser.keys('p'); + expect(await paint.getAttribute('class')).not.toContain( + 'tool-btn-selected' + ); + await name().execute((element) => element.focus()); + await browser.keys('Escape'); + await dialog().waitForDisplayed({ reverse: true }); + expect(await segmentNames()).toEqual(['Segment 1']); + + await edit.click(); + await dialog().waitForDisplayed(); + expect(await name().getValue()).toEqual('Segment 1'); + await setValueVueInput(name(), 'Committed'); + await browser.keys('Enter'); + await dialog().waitForDisplayed({ reverse: true }); + expect(await segmentNames()).toEqual(['Committed']); + await row.execute((element) => element.focus()); + await browser.keys('p'); + expect(await paint.getAttribute('class')).toContain('tool-btn-selected'); + }); + + it('names segment actions and exposes keyboard selection and measurement disclosure', async () => { + const { centerX, centerY } = await setupTest(); + await volViewPage.activateRectangle(); + await openAnnotationSegments(); + await clickAt(centerX - 40, centerY - 40); + await clickAt(centerX + 40, centerY + 40); + + const row = await segmentRow('Segment 1'); + const expander = $('[data-testid="measurements-section"]'); + const shape = () => $('[data-testid="segment-shape-row"]'); + await shape().waitForDisplayed(); + expect(await expander.getComputedLabel()).toEqual('Measurements'); + expect(await expander.getAttribute('aria-expanded')).toEqual('true'); + await expander.execute((element) => element.focus()); + await browser.keys('Enter'); + await shape().waitForExist({ reverse: true }); + expect(await expander.getAttribute('aria-expanded')).toEqual('false'); + await browser.keys(' '); + await shape().waitForDisplayed(); + expect(await expander.getAttribute('aria-expanded')).toEqual('true'); + + const create = $('[data-testid="segment-list"] .create-row'); + expect(await create.getComputedRole()).toEqual('button'); + expect(await create.getComputedLabel()).toEqual('New segment'); + await create.execute((element) => element.focus()); + await browser.keys('Enter'); + expect(await segmentNames()).toEqual(['Segment 1', 'Segment 2']); + const second = await segmentRow('Segment 2'); + await second.execute((element) => element.focus()); + await browser.keys('Enter'); + expect(await selectedSegmentName()).toEqual('Segment 2'); + expect(await second.getAttribute('aria-current')).toEqual('true'); + expect(await row.getAttribute('aria-current')).toBeNull(); + await row.execute((element) => element.focus()); + await browser.keys(' '); + expect(await selectedSegmentName()).toEqual('Segment 1'); + expect(await row.getAttribute('aria-current')).toEqual('true'); + expect(await second.getAttribute('aria-current')).toBeNull(); + + await renameSegment('Segment 1', 'Lesion'); + const controls = [ + ['segment-color-button', 'Change color for Lesion'], + ['reveal-segment-button', 'Reveal slice for Lesion'], + ['edit-segment-button', 'Edit Lesion'], + ['delete-segment-button', 'Delete Lesion from every image'], + ['save-segments-button', 'Save segments'], + ]; + for (const [testId, label] of controls) { + expect(await $(`[data-testid="${testId}"]`).getComputedLabel()).toEqual( + label + ); + } + const hide = row.$('button:has(i.mdi-eye)'); + expect(await hide.getComputedLabel()).toEqual('Hide Lesion'); + await hide.execute((element) => element.focus()); + await browser.keys('Enter'); + expect(await hide.getComputedLabel()).toEqual('Show Lesion'); + await browser.keys(' '); + expect(await hide.getComputedLabel()).toEqual('Hide Lesion'); + await lockSegment('Lesion'); + expect(await row.$('button:has(i.mdi-lock)').getComputedLabel()).toEqual( + 'Unlock Lesion' + ); + expect(await row.$('[data-testid="edit-segment-button"]').isEnabled()).toBe( + false + ); + }); +}); + +describe('Reveal Slice on a segment', () => { + it('jumps the view back to a slice the segment covers', async () => { + await openUrls([PROSTATEX_DATASET]); + + await volViewPage.focusFirst2DView(); + await browser.waitUntil( + async () => (await volViewPage.getFirst2DSlice()) !== null, + { timeoutMsg: 'Slice overlay never appeared' } + ); + const paintedSlice = await volViewPage.getFirst2DSlice(); + + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + + await openAnnotationSegments(); + await waitForNamedSegments(); + expect(await segmentNames()).toEqual(['Segment 1']); + + // Scroll away so revealing has somewhere to jump back from. + await volViewPage.selectTool('mdi-cursor-default'); + await volViewPage.focusFirst2DView(); + await volViewPage.advanceSliceAndWait(); + await volViewPage.advanceSliceAndWait(); + expect(await volViewPage.getFirst2DSlice()).not.toEqual(paintedSlice); + + await revealSegment('Segment 1'); + + await browser.waitUntil( + async () => (await volViewPage.getFirst2DSlice()) === paintedSlice, + { timeoutMsg: `Expected the view to return to slice ${paintedSlice}` } + ); + }); +}); diff --git a/tests/specs/cine-ruler-session.e2e.ts b/tests/specs/cine-ruler-session.e2e.ts index 046806117..6d5bff934 100644 --- a/tests/specs/cine-ruler-session.e2e.ts +++ b/tests/specs/cine-ruler-session.e2e.ts @@ -16,6 +16,7 @@ import { retreatCineFrame, waitForFrame, } from './cineTestUtils'; +import { openSegmentShapes } from './segmentationTestUtils'; const placeRulerAtCanvasCenter = async () => { const rulerToolButton = await $('button span i[class~=mdi-ruler]'); @@ -64,15 +65,10 @@ describe('Cine ruler survives save/reload at its placed frame', () => { // before asserting visibility on the canvas. The list entry proves // deserialization has completed, so the subsequent canvas checks // can't race the load. - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - const measurementsTab = await $('button.v-tab*=Measurements'); - await measurementsTab.waitForClickable(); - await measurementsTab.click(); + await openSegmentShapes(); await browser.waitUntil( - async () => (await $$('.v-list-item i.mdi-ruler.tool-icon').length) >= 1, + async () => + (await $$('[data-testid="segment-shape-row"] i.mdi-ruler').length) >= 1, { timeoutMsg: 'Expected the deserialized ruler entry to appear in the list', diff --git a/tests/specs/configTestUtils.ts b/tests/specs/configTestUtils.ts index f290cad97..b8de006de 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -29,11 +29,12 @@ export const PROSTATE_610_LABELMAP_MANIFEST = { name: 'Prostate Segmentation', parentImage: '0', segments: { - order: [1], + // The fixture contains label 78 (hip_right), but no label 1. + order: [78], byValue: { - '1': { - value: 1, - name: 'Prostate', + '78': { + value: 78, + name: 'Right hip', color: [255, 0, 0, 255], visible: true, }, diff --git a/tests/specs/configurationImage.ts b/tests/specs/configurationImage.ts new file mode 100644 index 000000000..7a34c7383 --- /dev/null +++ b/tests/specs/configurationImage.ts @@ -0,0 +1,27 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; + +export const writeConfigImages = (stem: string) => { + const header = [ + 'NRRD0005', + 'type: unsigned char', + 'dimension: 3', + 'sizes: 8 8 8', + 'space: left-posterior-superior', + 'space directions: (1,0,0) (0,1,0) (0,0,1)', + 'space origin: (0,0,0)', + 'encoding: ascii', + ]; + const pixels = Array.from({ length: 512 }, (_, i) => + i % 8 > 2 ? 1 : 0 + ).join(' '); + writeFileSync( + join(TEMP_DIR, `${stem}.nrrd`), + `${header.join('\n')}\n\n${pixels}` + ); + writeFileSync( + join(TEMP_DIR, `${stem}.seg.nrrd`), + `${header.join('\n')}\nSegment0_LabelValue:=1\nSegment0_Name:=Matched mask\n\n${pixels}` + ); +}; diff --git a/tests/specs/delete-selected-annotation.e2e.ts b/tests/specs/delete-selected-annotation.e2e.ts index 8049dff73..84a6ae710 100644 --- a/tests/specs/delete-selected-annotation.e2e.ts +++ b/tests/specs/delete-selected-annotation.e2e.ts @@ -103,28 +103,29 @@ describe('Delete key on a selected annotation', () => { }); }); - // Checking a row in the annotations panel leaves focus on its checkbox, which - // must not swallow the delete key - it('deletes an annotation selected from the annotations panel', async () => { + // Working the annotations panel leaves focus on the control that was clicked, + // which must not swallow the delete key + it('deletes a selected annotation while the panel holds focus', async () => { const { axialView, centerX, centerY } = await setupTest(); await placeRectangle(centerX, centerY, 80); await waitForCircleCount(axialView, 2, 'Rectangle should have two handles'); + await clickToSelect(axialView, centerX - 80, centerY - 80); const annotationsTab = await AppPage.annotationsModuleTab; await annotationsTab.waitForClickable(); await annotationsTab.click(); - const rowCheckbox = await $('.v-list-item .v-selection-control__input'); - await rowCheckbox.waitForClickable(); - await rowCheckbox.click(); + const measurements = await $('[data-testid="measurements-section"]'); + await measurements.waitForClickable(); + await measurements.click(); await pressDelete(); await waitForCircleCount( axialView, 0, - 'Delete should work while the panel checkbox holds focus' + 'Delete should work while a panel control holds focus' ); }); diff --git a/tests/specs/different-direction-labelmap.e2e.ts b/tests/specs/different-direction-labelmap.e2e.ts index ff29a2585..5670438d0 100644 --- a/tests/specs/different-direction-labelmap.e2e.ts +++ b/tests/specs/different-direction-labelmap.e2e.ts @@ -1,14 +1,18 @@ import { PROSTATE_610_LABELMAP_MANIFEST } from './configTestUtils'; import { writeManifestToFile } from './utils'; import { volViewPage } from '../pageobjects/volview.page'; -import { DOWNLOAD_TIMEOUT } from '../../wdio.shared.conf'; +import { + openAnnotationSegments, + waitForNamedSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; /** * Regression test for labelmap with different direction matrix than parent image. * * The prostate DICOM and TotalSegmenter segment group have different direction matrices: * Base image: [1, 0, 0, 0, 0.97, -0.24, 0, 0.24, 0.97] - * Segment group: [1, 0, 0, 0, -0.97, 0.24, 0, 0.24, 0.97] + * Labelmap: [1, 0, 0, 0, -0.97, 0.24, 0, 0.24, 0.97] * * This caused bugs where paint tool painted at wrong location and * coronal slice didn't show segment overlay. @@ -33,25 +37,9 @@ describe('Labelmap with different direction matrix', () => { const notifications = await volViewPage.getNotificationsCount(); expect(notifications).toEqual(0); - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const segmentGroupsTab = await $('button.v-tab*=Segment Groups'); - await segmentGroupsTab.waitForClickable(); - await segmentGroupsTab.click(); - - await browser.waitUntil( - async () => { - const segmentGroups = await $$('.segment-group-list .v-list-item'); - return (await segmentGroups.length) >= 1; - }, - { - timeout: DOWNLOAD_TIMEOUT, - timeoutMsg: 'Segment group not found in segment groups list', - } - ); + await openAnnotationSegments(); + await waitForNamedSegments(); + await waitForSegmentContent('Right hip'); await volViewPage.openLayoutMenu(1); await volViewPage.selectLayoutOption('Coronal Only'); diff --git a/tests/specs/label-outline.e2e.ts b/tests/specs/label-outline.e2e.ts new file mode 100644 index 000000000..c6c03b2be --- /dev/null +++ b/tests/specs/label-outline.e2e.ts @@ -0,0 +1,87 @@ +import { startOutlineFixture } from '../fixtures/label-outline/server.mjs'; + +// Exercise the installed vtk.js shader in real WebGL. A DOM unit test cannot +// detect CLAMP_TO_EDGE turning an out-of-image neighbor into the same label. +describe('Bounded label outlines', () => { + let fixture: Awaited>; + + before(async () => { + fixture = await startOutlineFixture(); + }); + + after(async () => { + await fixture?.close(); + }); + + for (const axis of [0, 1, 2]) { + it(`outlines all four cropped edges on axis ${axis}`, async () => { + await browser.url(`${fixture.url}?axis=${axis}`); + await browser.waitUntil(() => + browser.execute(() => !!window.outlineResult) + ); + const result = await browser.execute(() => window.outlineResult); + for (const edge of ['left', 'right', 'bottom', 'top'] as const) { + expect(result[edge]).toBeGreaterThan(240); + } + expect(result.center).toBe(51); + expect(result.innerEdge).toBe(51); + expect(result.outside).toBe(0); + }); + } + + it('does not invent background at the scan boundary', async () => { + await browser.url(`${fixture.url}?axis=2&scanEdge`); + await browser.waitUntil(() => + browser.execute(() => !!window.outlineResult) + ); + const result = await browser.execute(() => window.outlineResult); + expect(result.right).toBe(51); + for (const edge of ['left', 'bottom', 'top'] as const) { + expect(result[edge]).toBeGreaterThan(240); + } + }); + + it('matches the original full-grid mask with and without scan truncation', async () => { + for (const scanEdge of ['', '&scanEdge']) { + const results = []; + for (const fullGrid of ['', '&fullGrid']) { + await browser.url(`${fixture.url}?axis=2${scanEdge}${fullGrid}`); + await browser.waitUntil(() => + browser.execute(() => !!window.outlineResult) + ); + results.push(await browser.execute(() => window.outlineResult)); + } + expect(results[0]).toEqual(results[1]); + } + }); + + it('keeps edges after repeated thickness and opacity updates', async () => { + await browser.url(`${fixture.url}?axis=2`); + await browser.waitUntil(() => + browser.execute(() => !!window.outlineResult) + ); + const highlighted = await browser.execute(() => window.renderOutline(5)); + expect(highlighted.innerEdge).toBeGreaterThan(240); + const faded = await browser.execute(() => window.renderOutline(3, 0.5)); + expect(faded.left).toBeGreaterThan(120); + expect(faded.left).toBeLessThan(135); + const disabled = await browser.execute(() => window.renderOutline(0)); + expect(disabled.left).toBe(51); + const restored = await browser.execute(() => window.renderOutline(3)); + expect(restored.left).toBeGreaterThan(240); + expect(restored.innerEdge).toBe(51); + expect(restored.center).toBe(51); + }); + + it('refreshes the reused texture after erasing and repainting the whole mask', async () => { + await browser.url(`${fixture.url}?axis=2`); + await browser.waitUntil(() => + browser.execute(() => !!window.outlineResult) + ); + const original = await browser.execute(() => window.outlineResult); + const erased = await browser.execute(() => window.editMask(0)); + expect(Object.values(erased).every((value) => value === 0)).toBe(true); + const repainted = await browser.execute(() => window.editMask(1)); + expect(repainted).toEqual(original); + }); +}); diff --git a/tests/specs/labelmap-import-roundtrip.e2e.ts b/tests/specs/labelmap-import-roundtrip.e2e.ts new file mode 100644 index 000000000..df11a302f --- /dev/null +++ b/tests/specs/labelmap-import-roundtrip.e2e.ts @@ -0,0 +1,168 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import JSZip from 'jszip'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { + openVolViewPage, + writeManifestToFile, + waitForDownload, + SESSION_SAVE_TIMEOUT, +} from './utils'; +import { + openAnnotationSegments, + segmentNames, + selectedSegmentName, + waitForSegmentContent, +} from './segmentationTestUtils'; + +const expectRestoredRegions = async () => { + await openAnnotationSegments(); + await browser.waitUntil(async () => + (await segmentNames()).includes('Left region') + ); + await waitForSegmentContent('Left region'); + await waitForSegmentContent('Right region'); + expect(await segmentNames()).toEqual(['Right region', 'Left region']); + expect(await selectedSegmentName()).toBe('Left region'); +}; + +const writeVolume = (name: string, mask: boolean) => { + const header = + 'NRRD0005\ntype: unsigned char\ndimension: 3\nspace: left-posterior-superior\nsizes: 8 8 8\nspace directions: (1,0,0) (0,1,0) (0,0,1)\nspace origin: (0,0,0)\nencoding: raw\n\n'; + const voxels = Buffer.from( + Array.from({ length: 512 }, (_, index) => { + const x = index % 8; + return mask ? (x === 2 ? 3 : x === 5 ? 7 : 0) : index % 256; + }) + ); + fs.writeFileSync( + path.join(TEMP_DIR, name), + Buffer.concat([Buffer.from(header), voxels]) + ); +}; + +const catalog = [ + { + id: 'left', + name: 'Left region', + color: [255, 0, 0, 255], + visible: true, + locked: false, + }, + { + id: 'right', + name: 'Right region', + color: [0, 255, 0, 255], + visible: true, + locked: false, + }, +]; +const provenance = { + providerId: 'test-provider', + jobId: 'test-job', + outputId: 'labels', +}; + +for (const legacy of [false, true]) { + describe(`${legacy ? 'Legacy group' : 'Composed labelmap'} import round trip`, () => { + it('restores mask content, order and selection, then saves independent mask files', async () => { + writeVolume('import-parent.nrrd', false); + writeVolume('import-labels.nrrd', true); + const dataSources = [ + { id: 1, type: 'uri', uri: '/tmp/import-parent.nrrd' }, + { id: 2, type: 'uri', uri: '/tmp/import-labels.nrrd' }, + { id: 3, type: 'collection', sources: [2] }, + ]; + const manifest = legacy + ? { + version: '6.4.0', + dataSources, + datasets: [{ id: 'parent', dataSourceId: 1 }], + segmentGroups: [ + { + id: 'labels', + dataSourceId: 3, + metadata: { + parentImage: 'parent', + name: 'Regions', + source: provenance, + segments: { + order: [7, 3], + byValue: { + '3': { value: 3, ...catalog[0] }, + '7': { value: 7, ...catalog[1] }, + }, + }, + }, + }, + ], + tools: { + paint: { activeSegmentGroupID: 'labels', activeSegment: 3 }, + }, + } + : { + version: '7.0.0', + dataSources, + datasets: [{ id: 'parent', dataSourceId: 1 }], + segments: [catalog[1], catalog[0]], + selectedSegment: 'left', + segmentations: [ + { + id: 'segmentation', + name: 'Regions', + parentImage: 'parent', + order: ['right', 'left'], + masks: catalog.map((segment, index) => ({ + id: segment.id, + segmentId: segment.id, + representations: { + labelmap: { + artifactId: 'labels', + sourceValue: index === 0 ? 3 : 7, + extent: [0, -1, 0, -1, 0, -1], + }, + }, + })), + }, + ], + segmentationArtifacts: [ + { + id: 'labels', + parentImage: 'parent', + name: 'Regions', + dataSourceId: 3, + source: provenance, + }, + ], + }; + const fileName = `labelmap-import-${legacy}.volview.json`; + await writeManifestToFile(manifest, fileName); + await openVolViewPage(fileName); + await expectRestoredRegions(); + + const savedName = await volViewPage.saveSession(); + const savedPath = path.join(TEMP_DIR, savedName); + await waitForDownload(savedPath, SESSION_SAVE_TIMEOUT); + const zip = await JSZip.loadAsync(fs.readFileSync(savedPath)); + const saved = JSON.parse( + await zip.file('manifest.json')!.async('string') + ); + expect(saved.segmentationArtifacts).toBeUndefined(); + const bindings = saved.segmentations.flatMap((segmentation: any) => + segmentation.masks.map((mask: any) => mask.representations.labelmap) + ); + expect(bindings).toHaveLength(2); + expect(new Set(bindings.map((binding: any) => binding.path)).size).toBe( + 2 + ); + for (const binding of bindings) { + expect(binding.artifactId).toBeUndefined(); + expect(binding.source).toEqual(provenance); + expect(zip.file(binding.path)).not.toBeNull(); + } + await openVolViewPage(savedName); + await expectRestoredRegions(); + }); + }); +} diff --git a/tests/specs/multiple-segmentation-import.e2e.ts b/tests/specs/multiple-segmentation-import.e2e.ts new file mode 100644 index 000000000..c88aef3a1 --- /dev/null +++ b/tests/specs/multiple-segmentation-import.e2e.ts @@ -0,0 +1,122 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { projectRoot } from '../e2eTestUtils'; +import { volViewPage } from '../pageobjects/volview.page'; +import { openVolViewPage, writeManifestToFile } from './utils'; +import { + openAnnotationSegments, + segmentNames, + waitForSegmentContent, +} from './segmentationTestUtils'; + +const dimensions = 8; + +const writeVolume = ( + name: string, + voxelAt: (i: number, j: number, k: number) => number, + segmentColor?: string +) => { + const segmentFields = segmentColor + ? `Segment0_LabelValue:=1\nSegment0_Name:=Tumor\nSegment0_Color:=${segmentColor}\n` + : ''; + const header = + 'NRRD0005\ntype: unsigned char\ndimension: 3\n' + + 'space: left-posterior-superior\nsizes: 8 8 8\n' + + 'space directions: (1,0,0) (0,1,0) (0,0,1)\n' + + `space origin: (0,0,0)\n${segmentFields}encoding: raw\n\n`; + const voxels = Buffer.from( + Array.from({ length: dimensions ** 3 }, (_, index) => { + const i = index % dimensions; + const j = Math.floor(index / dimensions) % dimensions; + const k = Math.floor(index / dimensions ** 2); + return voxelAt(i, j, k); + }) + ); + fs.writeFileSync( + path.join(TEMP_DIR, name), + Buffer.concat([Buffer.from(header), voxels]) + ); +}; + +const addAsSegmentation = async (name: string) => { + await $('button[data-testid="module-tab-Data"]').click(); + const card = $(`.v-card:has([title="${name}"])`); + await card.$('button.dataset-menu').click(); + const menuItem = $( + `//*[contains(@class,"v-overlay--active")]//*[contains(@class,"v-list-item") and contains(normalize-space(.),"Add as segmentation")]` + ); + await menuItem.$('.v-list-item__content').click(); + await card + .$('[data-testid="segmentation-conversion-progress"]') + .waitForDisplayed({ reverse: true }); +}; + +describe('Importing overlapping files with the same Slicer segment name', function () { + this.timeout(120_000); + + it('keeps both masks and gives their flat-list rows unique names', async () => { + writeVolume('multi-import-parent.nrrd', (i, j, k) => i + j + k); + writeVolume( + 'multi-import-left.seg.nrrd', + (i, j, k) => + i >= 2 && i <= 4 && j >= 2 && j <= 5 && k >= 2 && k <= 5 ? 1 : 0, + '1 0 0' + ); + writeVolume( + 'multi-import-right.seg.nrrd', + (i, j, k) => + i >= 3 && i <= 5 && j >= 2 && j <= 5 && k >= 2 && k <= 5 ? 1 : 0, + '0 1 0' + ); + await writeManifestToFile( + { + resources: [ + { + url: '/tmp/multi-import-parent.nrrd', + name: 'multi-import-parent.nrrd', + }, + { + url: '/tmp/multi-import-left.seg.nrrd', + name: 'multi-import-left.seg.nrrd', + }, + { + url: '/tmp/multi-import-right.seg.nrrd', + name: 'multi-import-right.seg.nrrd', + }, + ], + }, + 'multiple-segmentation-import.json' + ); + await openVolViewPage('multiple-segmentation-import.json'); + + await $('button[data-testid="module-tab-Data"]').click(); + await $('.v-card:has([title="multi-import-parent.nrrd"])').click(); + await addAsSegmentation('multi-import-left.seg.nrrd'); + await addAsSegmentation('multi-import-right.seg.nrrd'); + + await openAnnotationSegments(); + await browser.waitUntil( + async () => (await segmentNames()).join(',') === 'Tumor,Tumor (2)', + { timeoutMsg: 'Expected both imported Tumor masks in the segment list' } + ); + await waitForSegmentContent('Tumor'); + await waitForSegmentContent('Tumor (2)'); + expect(await segmentNames()).toEqual(['Tumor', 'Tumor (2)']); + + await volViewPage.clickSaveSegmentsButton(); + const notice = $('[data-testid="save-overlap-notice"]'); + await expect(notice).toBeDisplayed(); + expect(await notice.getText()).toBe( + 'Saving 2 files due to overlap, bundled into multi-import-parent.nrrd.zip.' + ); + if (process.env.CAPTURE_SEGMENT_IMPORT_DEMO) { + const demoDir = path.join(projectRoot(), '.tmp', 'demo'); + fs.mkdirSync(demoDir, { recursive: true }); + await browser.saveScreenshot( + path.join(demoDir, 'multiple-segmentation-import.png') + ); + } + }); +}); diff --git a/tests/specs/paint-eyedropper.e2e.ts b/tests/specs/paint-eyedropper.e2e.ts new file mode 100644 index 000000000..705511f87 --- /dev/null +++ b/tests/specs/paint-eyedropper.e2e.ts @@ -0,0 +1,140 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { openUrls } from './utils'; +import { allowOverlap, openAnnotationSegments } from './segmentationTestUtils'; + +const row = (name: string) => + $(`[data-testid="segment-list"] .item-row[aria-label="${name}"]`); +const selected = () => + $('[data-testid="segment-list"] .item-row[aria-current="true"]'); +const eyedropper = () => $('[data-testid="paint-eyedropper-button"]'); +const mouse = () => browser.action('pointer', { id: 'paint-mouse' }); +const keyboard = () => browser.action('key', { id: 'paint-keyboard' }); +const click = (x: number, y: number) => + mouse().move({ x, y }).down().up().perform(true); + +const expectBackgroundUnpainted = async (x: number, y: number) => { + await row('Segment 3').click(); + await eyedropper().click(); + await click(x, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 3'); +}; + +describe('Paint eyedropper', () => { + afterEach(async () => { + await browser.releaseActions(); + }); + + it('picks visible label maps without painting and restores the held mode', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.activatePaint(); + await openAnnotationSegments(); + const canvas = (await volViewPage.getViews2D())[0].$('canvas'); + const location = await canvas.getLocation(); + const size = await canvas.getSize(); + const x = Math.round(location.x + size.width / 2); + const y = Math.round(location.y + size.height / 2); + + await click(x, y); + await expect(row('Segment 1')).toExist(); + await allowOverlap(); + await $('[data-testid="segment-list"] .create-row').click(); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + await click(x, y); + await $('[data-testid="segment-list"] .create-row').click(); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 3'); + + const list = $('[data-testid="segment-list"] .item-list-scroll'); + await browser.execute( + (element) => { + element.style.maxHeight = '64px'; + element.scrollTop = element.scrollHeight; + }, + await list + ); + const selectedRowInView = async () => + browser.execute( + (element) => { + const selectedRow = element.querySelector('[aria-current="true"]')!; + const bounds = element.getBoundingClientRect(); + const item = selectedRow.getBoundingClientRect(); + return item.top >= bounds.top && item.bottom <= bounds.bottom; + }, + await list + ); + + await eyedropper().click(); + await expect(eyedropper()).toHaveAttribute('aria-pressed', 'true'); + await click(x, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + await browser.waitUntil(selectedRowInView); + await browser.execute( + (element) => { + element.scrollTop = element.scrollHeight; + }, + await list + ); + expect(await selectedRowInView()).toBe(false); + await click(x, y); + await browser.waitUntil(selectedRowInView); + await row('Segment 1').$('.reorder-handle').click(); + await browser.keys(['Alt', 'ArrowDown']); + await expect(row('Segment 2').$('kbd')).toHaveText('1'); + await click(x, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + await browser.waitUntil(selectedRowInView); + await row('Segment 2').$('button[aria-label="Hide Segment 2"]').click(); + await click(x, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + await click(x + 70, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + + await row('Segment 2').$('button[aria-label="Show Segment 2"]').click(); + await browser.keys('e'); + await row('Segment 3').click(); + await keyboard().down('d').perform(true); + await expect(eyedropper()).toHaveAttribute('aria-pressed', 'true'); + await browser.waitUntil(async () => + String((await canvas.getCSSProperty('cursor')).value).startsWith('url(') + ); + await mouse().move({ x, y }).down().perform(true); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + await keyboard().up('d').perform(true); + await expect(eyedropper()).toHaveAttribute('aria-pressed', 'false'); + await expect($('button.mode-button.selected')).toHaveText('Erase'); + await mouse() + .move({ x: x + 70, y }) + .up() + .perform(true); + + // Sampling leaves background untouched and the painted segment pickable. + await expectBackgroundUnpainted(x + 70, y); + await click(x, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + + await browser.keys('p'); + await mouse().move({ x, y }).down().perform(true); + await keyboard().down('d').perform(true); + await expect(eyedropper()).toHaveAttribute('aria-pressed', 'true'); + await mouse() + .move({ x: x + 70, y }) + .perform(true); + await keyboard().up('d').perform(true); + await mouse() + .move({ x: x + 80, y }) + .up() + .perform(true); + await expectBackgroundUnpainted(x + 70, y); + await click(x + 80, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 3'); + + await browser.keys('p'); + await row('Segment 3').$('[data-testid="edit-segment-button"]').click(); + const input = $('div[role="dialog"] .v-text-field input'); + await input.click(); + await keyboard().down('d').perform(true); + await expect(input).toHaveValue('Segment 3d'); + await expect(eyedropper()).toHaveAttribute('aria-pressed', 'false'); + await keyboard().up('d').perform(true); + }); +}); diff --git a/tests/specs/paint-fill-holes.e2e.ts b/tests/specs/paint-fill-holes.e2e.ts index 4aed2c70a..0c6477858 100644 --- a/tests/specs/paint-fill-holes.e2e.ts +++ b/tests/specs/paint-fill-holes.e2e.ts @@ -2,6 +2,15 @@ import AppPage from '../pageobjects/volview.page'; import { PROSTATEX_DATASET } from '../datasets'; import { openUrls } from './utils'; +async function startFillHolesPreview() { + await AppPage.processModeButton.waitForClickable(); + await AppPage.processModeButton.click(); + await AppPage.selectFillHolesProcess(); + await AppPage.processPreviewButton.waitForClickable(); + await AppPage.processPreviewButton.click(); + await AppPage.processApplyButton.waitForDisplayed(); +} + describe('Fill Holes paint process', () => { beforeEach(async () => { await openUrls([PROSTATEX_DATASET]); @@ -44,13 +53,8 @@ describe('Fill Holes paint process', () => { await expect(AppPage.processPreviewButton).toBeDisplayed(); }); - it('toggles the preview in place between processed and original', async () => { - await AppPage.processModeButton.waitForClickable(); - await AppPage.processModeButton.click(); - await AppPage.selectFillHolesProcess(); - - await AppPage.processPreviewButton.waitForClickable(); - await AppPage.processPreviewButton.click(); + it('selects the named processed and original previews', async () => { + await startFillHolesPreview(); // Previewing starts on the processed result. await AppPage.processProcessedButton.waitForDisplayed(); @@ -61,14 +65,38 @@ describe('Fill Holes paint process', () => { await AppPage.isPreviewToggleActive(AppPage.processOriginalButton) ).toBe(false); - // Clicking the already-active button flips the preview in place, without - // moving the pointer to the other button. + // Re-selecting the active choice leaves that named preview selected. await AppPage.processProcessedButton.click(); await browser.waitUntil(() => - AppPage.isPreviewToggleActive(AppPage.processOriginalButton) + AppPage.isPreviewToggleActive(AppPage.processProcessedButton) ); expect( - await AppPage.isPreviewToggleActive(AppPage.processProcessedButton) + await AppPage.isPreviewToggleActive(AppPage.processOriginalButton) ).toBe(false); + + await AppPage.processOriginalButton.click(); + await browser.waitUntil(() => + AppPage.isPreviewToggleActive(AppPage.processOriginalButton) + ); }); + + for (const preview of ['Original', 'Processed']) { + it(`cancels the ${preview} preview when its segment locks and allows a retry`, async () => { + await startFillHolesPreview(); + if (preview === 'Original') await AppPage.processOriginalButton.click(); + const lock = $('[data-testid="toggle-segments-locked-button"]'); + await lock.waitForClickable(); + await lock.click(); + await expect(AppPage.processPreviewButton).toBeDisplayed(); + await expect(AppPage.processApplyButton).not.toBeDisplayed(); + + await lock.click(); + await AppPage.processPreviewButton.waitForClickable(); + await AppPage.processPreviewButton.click(); + await AppPage.processApplyButton.waitForClickable(); + await AppPage.processApplyButton.click(); + await expect(AppPage.processPreviewButton).toBeDisplayed(); + await expect($('div*=Operation Failed')).not.toBeDisplayed(); + }); + } }); diff --git a/tests/specs/paint-tool-rendering.e2e.ts b/tests/specs/paint-tool-rendering.e2e.ts index e591409b2..13d8f384c 100644 --- a/tests/specs/paint-tool-rendering.e2e.ts +++ b/tests/specs/paint-tool-rendering.e2e.ts @@ -1,6 +1,7 @@ import AppPage from '../pageobjects/volview.page'; import { PROSTATEX_DATASET } from '../datasets'; import { openUrls } from './utils'; +import { moveTo } from './annotationTestUtils'; describe('Paint tool rendering', () => { it('should not black out axial view after painting', async () => { @@ -48,5 +49,38 @@ describe('Paint tool rendering', () => { interval: 1000, } ); + + const canvasImage = () => + browser.execute( + (element) => (element as HTMLCanvasElement).toDataURL(), + canvas + ); + const hovered = await canvasImage(); + await moveTo(10, 10); + await browser.waitUntil(async () => (await canvasImage()) !== hovered, { + timeoutMsg: 'Brush preview should disappear when leaving the view', + }); + const withoutPreview = await canvasImage(); + + await moveTo(centerX - 60, centerY); + await browser.waitUntil( + async () => (await canvasImage()) !== withoutPreview, + { timeoutMsg: 'Brush preview should appear without painting' } + ); + const firstPreview = await canvasImage(); + await moveTo(centerX - 30, centerY); + await browser.waitUntil( + async () => (await canvasImage()) !== firstPreview, + { timeoutMsg: 'Brush preview should follow the pointer without painting' } + ); + + await moveTo(10, 10); + await browser.waitUntil( + async () => (await canvasImage()) === withoutPreview, + { + timeoutMsg: + 'Moving the preview should leave the painted image unchanged', + } + ); }); }); diff --git a/tests/specs/polygon-rasterize-segment.e2e.ts b/tests/specs/polygon-rasterize-segment.e2e.ts new file mode 100644 index 000000000..82d46b561 --- /dev/null +++ b/tests/specs/polygon-rasterize-segment.e2e.ts @@ -0,0 +1,240 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import JSZip from 'jszip'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import AppPage from '../pageobjects/volview.page'; +import type { ChainablePromiseElement } from 'webdriverio'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { + drawSquare, + nudgeTo, + rightPressAtPointer, + setupTest, +} from './annotationTestUtils'; +import { + addSegment, + lockSegment, + openAnnotationSegments, + renameSegment, + segmentColor, + segmentNames, + waitForNamedSegments, +} from './segmentationTestUtils'; +import { CINE_US_DATASET } from '../datasets'; +import { + openUrls, + openVolViewPage, + SESSION_SAVE_TIMEOUT, + waitForDownload, +} from './utils'; + +const RASTERIZE_ITEM = '.v-list-item-title=Rasterize'; + +const rasterizeMenuParts = async () => { + const title = await $(RASTERIZE_ITEM); + const item = await title.$('..').$('..'); + return { item, activator: await item.$('..') }; +}; + +const tooltipFor = async (element: ChainablePromiseElement) => { + const id = await element.getAttribute('aria-describedby'); + expect(id).toBeTruthy(); + return $(`[id="${id}"]`); +}; + +// The menu comes off the widget's own pick, so hover the handle first and press +// without moving. +const openPolygonMenuAt = (x: number, y: number) => + browser.waitUntil( + async () => { + await nudgeTo(x, y); + await rightPressAtPointer(); + return $(RASTERIZE_ITEM).isDisplayed(); + }, + { + interval: 500, + timeoutMsg: 'Right-clicking a polygon handle should open its menu', + } + ); + +describe('Polygon rasterize target', () => { + it('rasterizes into the segment the polygon was drawn with', async () => { + const { centerX, centerY } = await setupTest(); + const half = 60; + + // Paint a stroke first, so the image already holds a segment the polygon + // must not borrow. Activating the tool alone creates nothing. + await AppPage.activatePaint(); + const views2D = await AppPage.getViews2D(); + await AppPage.paintStrokeOnView(views2D[0]); + await AppPage.selectTool('mdi-pentagon-outline'); + + // Polygon draws with the entry selected in the Segments list, so the paint + // stroke's segment is already there and the new one is the second. + await openAnnotationSegments(); + await waitForNamedSegments(); + await addSegment(); + await renameSegment('Segment 2', 'Lesion'); + const lesionColor = await segmentColor('Lesion'); + + await drawSquare(centerX, centerY, half); + + expect(await segmentNames()).toEqual(['Segment 1', 'Lesion']); + + // The context menu belongs to a placed polygon, and the polygon tool keeps + // a placing one that would swallow the right click. + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(centerX + half, centerY - half); + await $(RASTERIZE_ITEM).click(); + + // Rasterizing lands in the polygon's own segment: it neither mints a + // second one nor borrows the segment the paint stroke made. + expect(await segmentNames()).toEqual(['Segment 1', 'Lesion']); + expect(await segmentColor('Lesion')).toEqual(lesionColor); + }); + + it('rasterizes a polygon drawn against an empty registry', async () => { + const { centerX, centerY } = await setupTest(); + const half = 60; + + // No paint stroke and no added entry, so the registry is empty and the + // polygon mints the segment it needs. Rasterize still has to work. + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY, half); + + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(centerX + half, centerY - half); + await $(RASTERIZE_ITEM).click(); + + await openAnnotationSegments(); + await waitForNamedSegments(); + await browser.waitUntil(async () => (await segmentNames()).length === 1, { + timeoutMsg: + 'Rasterizing against an empty registry should create one segment', + }); + }); + + it('explains a locked rasterize target on hover and keyboard focus', async () => { + const { centerX, centerY } = await setupTest(); + const half = 60; + await openAnnotationSegments(); + await addSegment(); + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY, half); + await lockSegment('Segment 1'); + + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(centerX + half, centerY - half); + let { item, activator } = await rasterizeMenuParts(); + expect(await item.getAttribute('class')).toContain('v-list-item--disabled'); + + await activator.moveTo(); + let tooltip = await tooltipFor(activator); + await expect(tooltip).toBeDisplayed(); + await expect(tooltip).toHaveText( + 'Unlock this segment to rasterize into it' + ); + + await activator.execute((element) => element.focus()); + tooltip = await tooltipFor(activator); + await expect(tooltip).toBeDisplayed(); + + await browser.keys('Escape'); + const row = await $('[data-testid="segment-list"] .item-row'); + await row.$('button i[class~="mdi-lock"]').click(); + await openPolygonMenuAt(centerX + half, centerY - half); + ({ item, activator } = await rasterizeMenuParts()); + expect(await item.getAttribute('class')).not.toContain( + 'v-list-item--disabled' + ); + await item.click(); + await waitForNamedSegments(); + }); + + it('disables Rasterize for the locked first segment after an unnamed restore', async () => { + const { centerX, centerY } = await setupTest(); + const half = 60; + await openAnnotationSegments(); + await addSegment(); + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY, half); + await lockSegment('Segment 1'); + + const savedName = await AppPage.saveSession(); + const savedPath = path.join(TEMP_DIR, savedName); + await waitForDownload(savedPath, SESSION_SAVE_TIMEOUT); + const zip = await JSZip.loadAsync(fs.readFileSync(savedPath)); + const manifest = JSON.parse( + await zip.file('manifest.json')!.async('string') + ); + delete manifest.selectedSegment; + delete manifest.tools.polygons.tools[0].segmentId; + zip.file('manifest.json', JSON.stringify(manifest)); + + const restoredName = 'restored-unnamed-locked-polygon.volview.zip'; + const restoredPath = path.join(TEMP_DIR, restoredName); + fs.writeFileSync( + restoredPath, + await zip.generateAsync({ type: 'nodebuffer' }) + ); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(restoredPath)) fs.unlinkSync(restoredPath); + }); + + await openVolViewPage(restoredName); + const [axialView] = await AppPage.getViews2D(); + await browser.waitUntil( + async () => { + const circles = await axialView.$$('svg circle'); + return (await circles.length) === 4; + }, + { timeoutMsg: 'The restored polygon should render all four handles' } + ); + const canvas = await axialView.$('canvas'); + const [location, size] = await Promise.all([ + canvas.getLocation(), + canvas.getSize(), + ]); + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt( + location.x + size.width / 2 + half, + location.y + size.height / 2 - half + ); + + const { item, activator } = await rasterizeMenuParts(); + expect(await item.getAttribute('class')).toContain('v-list-item--disabled'); + await activator.moveTo(); + const tooltip = await tooltipFor(activator); + await expect(tooltip).toBeDisplayed(); + await expect(tooltip).toHaveText( + 'Unlock this segment to rasterize into it' + ); + }); + + it('keeps Rasterize visible on cine and explains why it is disabled', async () => { + await openUrls([CINE_US_DATASET]); + const [view] = await AppPage.getViews2D(); + const canvas = await view.$('canvas'); + const [location, size] = await Promise.all([ + canvas.getLocation(), + canvas.getSize(), + ]); + const centerX = location.x + size.width / 2; + const centerY = location.y + size.height / 2; + const half = 30; + + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY, half); + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(centerX + half, centerY - half); + + const { item, activator } = await rasterizeMenuParts(); + expect(await item.getAttribute('class')).toContain('v-list-item--disabled'); + await activator.moveTo(); + const tooltip = $('.v-tooltip.v-overlay--active .v-overlay__content'); + await expect(tooltip).toBeDisplayed(); + await expect(tooltip).toHaveText( + 'Rasterization is not supported for cine images' + ); + }); +}); diff --git a/tests/specs/reveal-slice.e2e.ts b/tests/specs/reveal-slice.e2e.ts index ff123e77d..6d9a7f514 100644 --- a/tests/specs/reveal-slice.e2e.ts +++ b/tests/specs/reveal-slice.e2e.ts @@ -7,22 +7,14 @@ import { getCineFrame, waitForFrame, } from './cineTestUtils'; - -const openMeasurementsTab = async () => { - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const measurementsTab = await $('button.v-tab*=Measurements'); - await measurementsTab.waitForClickable(); - await measurementsTab.click(); -}; +import { openSegmentShapes, revealSegment } from './segmentationTestUtils'; const waitForToolEntry = async (iconClass: string) => { await browser.waitUntil( async () => { - const entries = await $$(`.v-list-item i.${iconClass}.tool-icon`); + const entries = await $$( + `[data-testid="segment-shape-row"] i.${iconClass}` + ); return (await entries.length) >= 1; }, { timeoutMsg: `Tool entry with icon ${iconClass} not found` } @@ -30,9 +22,10 @@ const waitForToolEntry = async (iconClass: string) => { }; const clickRevealSliceButton = async () => { - // The reveal-slice button is the v-btn wrapping the mdi-target icon - // inside the measurement tool list entry. - const button = await $('.v-list-item button .mdi-target'); + // The shape's own reveal, under its segment: the segment row carries one too. + const button = await $( + '[data-testid="segment-shape-row"] button[data-testid="reveal-shape-button"]' + ); await button.waitForClickable(); await button.click(); }; @@ -103,7 +96,7 @@ describe('Reveal Slice on a volume image', () => { const movedSlice = await volViewPage.getFirst2DSlice(); expect(movedSlice).not.toBe(placementSlice); - await openMeasurementsTab(); + await openSegmentShapes(); await waitForToolEntry('mdi-ruler'); await clickRevealSliceButton(); @@ -149,10 +142,18 @@ describe('Reveal Slice on cine ultrasound', () => { 'Expected the placed ruler to be hidden on frames other than the placement frame', }); - await openMeasurementsTab(); + await openSegmentShapes(); await waitForToolEntry('mdi-ruler'); await clickRevealSliceButton(); await waitForFrame(placementFrame!); + + await volViewPage.focusFirst2DView(); + await advanceCineFrame(); + await revealSegment('Segment 1'); + await waitForFrame(placementFrame!); + await browser.waitUntil(async () => (await countCineRulerLines()) >= 1, { + timeoutMsg: 'Segment reveal should restore its cine annotation frame', + }); }); }); diff --git a/tests/specs/seg-nrrd-export.e2e.ts b/tests/specs/seg-nrrd-export.e2e.ts index 582849d81..2fed7e2f8 100644 --- a/tests/specs/seg-nrrd-export.e2e.ts +++ b/tests/specs/seg-nrrd-export.e2e.ts @@ -4,7 +4,7 @@ import * as zlib from 'node:zlib'; import JSZip from 'jszip'; import { volViewPage } from '../pageobjects/volview.page'; import { TEMP_DIR } from '../../wdio.shared.conf'; -import { waitForFileExists } from './utils'; +import { waitForDownload } from './utils'; import { openConfigAndDataset } from './configTestUtils'; import { ONE_CT_SLICE_DICOM } from '../datasets'; @@ -65,23 +65,7 @@ describe('Slicer-compatible seg.nrrd export', function () { const sessionFileName = await volViewPage.saveSession(); const downloadedPath = path.join(TEMP_DIR, sessionFileName); - await waitForFileExists(downloadedPath, 30_000); - - // Wait for file to be fully written - await browser.waitUntil( - () => { - try { - return fs.statSync(downloadedPath).size > 0; - } catch { - return false; - } - }, - { - timeout: 10_000, - interval: 500, - timeoutMsg: 'Downloaded session zip remained 0 bytes', - } - ); + await waitForDownload(downloadedPath, 30_000); // Extract the seg.nrrd file from the session zip const zipData = fs.readFileSync(downloadedPath); diff --git a/tests/specs/segment-controls-accessibility.e2e.ts b/tests/specs/segment-controls-accessibility.e2e.ts new file mode 100644 index 000000000..0d66d2ab1 --- /dev/null +++ b/tests/specs/segment-controls-accessibility.e2e.ts @@ -0,0 +1,167 @@ +import { Key } from 'webdriverio'; +import { CONTENT_VIEWPORT } from '../../wdio.shared.conf'; +import AppPage, { setValueVueInput } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { + openAnnotationSegments, + segmentColor, + segmentNames, + segmentRow, + tooltipOf, + waitForNamedSegments, +} from './segmentationTestUtils'; +import { openUrls } from './utils'; + +describe('Segment control accessibility', () => { + beforeEach(async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + }); + + afterEach(async () => { + await browser.setViewport({ ...CONTENT_VIEWPORT, devicePixelRatio: 1 }); + }); + + it('names each interactive paint parameter', async () => { + // Picking up the brush opens its controls without a click on the panel. + await AppPage.activatePaint(); + + const brush = $('[role="slider"][aria-label="Brush size"]'); + await brush.waitForDisplayed(); + const initialSize = await brush.getAttribute('aria-valuenow'); + await brush.execute((element) => element.focus()); + await browser.keys(Key.ArrowRight); + expect(await brush.getAttribute('aria-valuenow')).not.toBe(initialSize); + + const minimum = $('input[aria-label="Minimum threshold"]'); + const maximum = $('input[aria-label="Maximum threshold"]'); + await minimum.waitForDisplayed(); + await expect(maximum).toBeDisplayed(); + await expect( + $('[role="slider"][aria-label="Minimum threshold"]') + ).toBeDisplayed(); + await expect( + $('[role="slider"][aria-label="Maximum threshold"]') + ).toBeDisplayed(); + const sync = $('input[aria-label="Sync Views"]'); + await expect(sync).toExist(); + expect(await sync.getComputedRole()).toBe('checkbox'); + const initiallySelected = await sync.isSelected(); + await sync.execute((element) => element.focus()); + await browser.keys(' '); + expect(await sync.isSelected()).toBe(!initiallySelected); + + // Each switch toggles from its label too. + await $('label*=Sync Views').click(); + expect(await sync.isSelected()).toBe(initiallySelected); + const overlap = $('input[aria-label="Allow Overlap"]'); + await $('label*=Allow Overlap').click(); + await expect(overlap).toBeSelected(); + }); + + it('disables the brush controls, saying why, until the Paint tool is picked', async () => { + await openAnnotationSegments(); + await $('button.v-expansion-panel-title*=Paint').click(); + const erase = $('button.mode-button*=Erase'); + const sync = $('input[aria-label="Sync Views"]'); + await expect(erase).toBeDisabled(); + await expect(sync).toBeDisabled(); + // It also decides how a polygon rasterizes, which needs no brush. + await expect($('input[aria-label="Allow Overlap"]')).toBeEnabled(); + + // Reached by keyboard from the panel title, so no pointer position is + // involved: the disabled modes first, then the brush parameters. + await browser.keys('Tab'); + const modes = await $(() => document.activeElement as HTMLElement); + const modesReason = await tooltipOf(modes); + await expect(modesReason).toBeDisplayed(); + await expect(modesReason).toHaveText( + 'Select the Paint tool to paint, erase or pick a segment' + ); + await browser.keys('Tab'); + const parameters = $('.paint-parameters'); + await expect(parameters).toBeFocused(); + const reason = await tooltipOf(parameters); + await expect(reason).toBeDisplayed(); + await expect(reason).toHaveText( + 'Select the Paint tool to adjust the brush' + ); + + await AppPage.activatePaint(); + await expect(erase).toBeEnabled(); + await expect(sync).toBeEnabled(); + }); + + it('keeps the segment editor usable at 375px and side by side on desktop', async () => { + await AppPage.activatePaint(); + const view = $('div[data-testid~="vtk-two-view"]'); + await AppPage.paintStrokeOnView(view); + await openAnnotationSegments(); + await waitForNamedSegments(); + const row = await segmentRow('Segment 1'); + const originalColor = await segmentColor('Segment 1'); + await row.$('[data-testid="edit-segment-button"]').click(); + + const dialog = $('div[role="dialog"]'); + await dialog.waitForDisplayed(); + const layout = dialog.$('.label-editor-layout'); + const fields = dialog.$('.label-editor-fields'); + const picker = dialog.$('.label-color-picker'); + const desktopLayout = await browser.execute( + (fieldsElement, pickerElement) => { + return { + fieldsLeft: fieldsElement.offsetLeft, + fieldsWidth: fieldsElement.clientWidth, + pickerLeft: pickerElement.offsetLeft, + }; + }, + await fields, + await picker + ); + expect(desktopLayout.pickerLeft).toBeGreaterThanOrEqual( + desktopLayout.fieldsLeft + desktopLayout.fieldsWidth + ); + + await browser.setViewport({ + width: 640, + height: CONTENT_VIEWPORT.height, + devicePixelRatio: 1, + }); + await browser.setViewport({ + width: 375, + height: CONTENT_VIEWPORT.height, + devicePixelRatio: 1, + }); + expect(await browser.execute(() => window.innerWidth)).toBe(375); + const narrowLayout = await browser.execute( + (layoutElement, fieldsElement, pickerElement) => { + return { + fieldsHeight: fieldsElement.clientHeight, + pickerOffsetTop: pickerElement.offsetTop, + pickerWidth: pickerElement.clientWidth, + layoutWidth: layoutElement.clientWidth, + }; + }, + await layout, + await fields, + await picker + ); + expect(narrowLayout.pickerWidth).toBeGreaterThan(250); + expect(narrowLayout.pickerOffsetTop).toBeGreaterThanOrEqual( + narrowLayout.fieldsHeight + ); + expect(narrowLayout.pickerWidth).toBeLessThanOrEqual( + narrowLayout.layoutWidth + ); + await setValueVueInput(dialog.$('.v-color-picker-edit input'), '0'); + const nameInput = dialog.$('input[type="text"]'); + await setValueVueInput(nameInput, 'Narrow segment'); + expect(await nameInput.getValue()).toBe('Narrow segment'); + await AppPage.editLabelModalDoneButton.scrollIntoView(); + await AppPage.editLabelModalDoneButton.click(); + await dialog.waitForDisplayed({ reverse: true }); + await browser.setViewport({ ...CONTENT_VIEWPORT, devicePixelRatio: 1 }); + await openAnnotationSegments(); + expect(await segmentNames()).toEqual(['Narrow segment']); + expect(await segmentColor('Narrow segment')).not.toBe(originalColor); + }); +}); diff --git a/tests/specs/segment-group-download.e2e.ts b/tests/specs/segment-group-download.e2e.ts index 52c97be0c..168292185 100644 --- a/tests/specs/segment-group-download.e2e.ts +++ b/tests/specs/segment-group-download.e2e.ts @@ -2,17 +2,13 @@ import * as path from 'path'; import * as fs from 'fs'; import { cleanuptotal } from 'wdio-cleanuptotal-service'; import { openUrls, waitForFileExists } from './utils'; -import { volViewPage } from '../pageobjects/volview.page'; +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; import { TEMP_DIR } from '../../wdio.shared.conf'; import { PROSTATEX_DATASET } from '../datasets'; +import { openAnnotationSegments } from './segmentationTestUtils'; const SAVE_TIMEOUT = 40000; -const loadSampleWithSegmentGroup = async (name: string) => { - await openUrls([PROSTATEX_DATASET]); - await volViewPage.createSegmentGroup(name); -}; - const prepareDownloadedFilePath = (fileName: string) => { const downloadedPath = path.join(TEMP_DIR, fileName); if (fs.existsSync(downloadedPath)) { @@ -26,41 +22,72 @@ const prepareDownloadedFilePath = (fileName: string) => { return downloadedPath; }; -const expectDirectSegmentGroupDownload = async ( - segmentGroupName: string, +// A stroke is what gives the image a mask to save: adding a type creates +// identity only. +const paintOnViewedImage = async () => { + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + await openAnnotationSegments(); +}; + +// The name a download carries is the one typed into the save dialog. +const expectDirectSegmentDownload = async ( + typedName: string, expectedStem: string ) => { - await loadSampleWithSegmentGroup(segmentGroupName); + await openUrls([PROSTATEX_DATASET]); + await openAnnotationSegments(); + await paintOnViewedImage(); - await volViewPage.clickFirstSegmentGroupSaveButton(); + await volViewPage.clickSaveSegmentsButton(); - const input = await volViewPage.saveSegmentGroupFilenameInput; + const input = await volViewPage.saveSegmentsFilenameInput; await input.waitForDisplayed(); - expect(await input.getValue()).toEqual(expectedStem); + await setValueVueInput(input, typedName); const downloadedPath = prepareDownloadedFilePath(`${expectedStem}.seg.nrrd`); - const confirm = await volViewPage.saveSegmentGroupConfirmButton; + const confirm = await volViewPage.saveSegmentsConfirmButton; await confirm.click(); await waitForFileExists(downloadedPath, SAVE_TIMEOUT); }; -describe('Segment group download', () => { - it('sanitizes invalid characters for direct segment group downloads', async () => { - await expectDirectSegmentGroupDownload( +describe('Segment download', () => { + it('sanitizes invalid characters for direct segment downloads', async () => { + await expectDirectSegmentDownload( 'Liver: left/right*?', 'Liver left right' ); }); - it('sanitizes reserved Windows names for direct segment group downloads', async () => { - await expectDirectSegmentGroupDownload('CON', 'CON_'); + it('sanitizes reserved Windows names for direct segment downloads', async () => { + await expectDirectSegmentDownload('CON', 'CON_'); }); - it('preserves valid segment group names for direct segment group downloads', async () => { - await expectDirectSegmentGroupDownload( + it('preserves valid names for direct segment downloads', async () => { + await expectDirectSegmentDownload( 'Prostate Segmentation', 'Prostate Segmentation' ); }); + + it('names the download after the viewed image by default', async () => { + await openUrls([PROSTATEX_DATASET]); + await openAnnotationSegments(); + await paintOnViewedImage(); + + await volViewPage.clickSaveSegmentsButton(); + + const input = await volViewPage.saveSegmentsFilenameInput; + await input.waitForDisplayed(); + const stem = await input.getValue(); + expect(stem).toBe('t2_tse_tra'); + + const downloadedPath = prepareDownloadedFilePath(`${stem}.seg.nrrd`); + const confirm = await volViewPage.saveSegmentsConfirmButton; + await confirm.click(); + + await waitForFileExists(downloadedPath, SAVE_TIMEOUT); + }); }); diff --git a/tests/specs/segment-group-list-scroll.e2e.ts b/tests/specs/segment-group-list-scroll.e2e.ts index 94d984b18..d5227721c 100644 --- a/tests/specs/segment-group-list-scroll.e2e.ts +++ b/tests/specs/segment-group-list-scroll.e2e.ts @@ -1,29 +1,31 @@ import { volViewPage } from '../pageobjects/volview.page'; import { openUrls } from './utils'; import { PROSTATEX_DATASET } from '../datasets'; +import { addSegment, openAnnotationSegments } from './segmentationTestUtils'; -// Six 48px rows overflow the list's 240px cap. -const GROUP_COUNT = 6; +const SEGMENT_COUNT = 20; -describe('Segment group list', () => { - it('lets overflowing segment groups scroll', async () => { +describe('Segment list', () => { + it('lets the module panel scroll when segments overflow it', async () => { await openUrls([PROSTATEX_DATASET]); + await openAnnotationSegments(); - for (let i = 0; i < GROUP_COUNT; i++) { - await volViewPage.createSegmentGroup(`Group ${i + 1}`); + for (let i = 0; i < SEGMENT_COUNT; i++) { + await addSegment(); } - const list = await volViewPage.segmentGroupList; + const list = await volViewPage.segmentList; await list.waitForDisplayed(); - const rows = await list.$$('.v-list-item'); - expect(rows.length).toEqual(GROUP_COUNT); + const segments = await list.$$('.item-row .v-list-item-title'); + expect(segments.length).toEqual(SEGMENT_COUNT); - const scrollHeight = Number(await list.getProperty('scrollHeight')); - const clientHeight = Number(await list.getProperty('clientHeight')); + const panel = await $('#module-container'); + const scrollHeight = Number(await panel.getProperty('scrollHeight')); + const clientHeight = Number(await panel.getProperty('clientHeight')); expect(scrollHeight).toBeGreaterThan(clientHeight); - const overflowY = await list.getCSSProperty('overflow-y'); + const overflowY = await panel.getCSSProperty('overflow-y'); expect(overflowY.value).toEqual('auto'); }); }); diff --git a/tests/specs/segment-identity-stability.e2e.ts b/tests/specs/segment-identity-stability.e2e.ts new file mode 100644 index 000000000..7909f063a --- /dev/null +++ b/tests/specs/segment-identity-stability.e2e.ts @@ -0,0 +1,106 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { openUrls, waitForDownload } from './utils'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { + openAnnotationSegments, + waitForNamedSegments, +} from './segmentationTestUtils'; + +const SAVE_TIMEOUT = 40_000; + +const isGzip = (buf: Buffer) => buf[0] === 0x1f && buf[1] === 0x8b; + +const parseHeader = (headerText: string) => { + const header = new Map(); + headerText.split('\n').forEach((line) => { + const keyValue = line.indexOf(':='); + if (keyValue >= 0) { + const key = line.slice(0, keyValue).trim(); + header.set(key, line.slice(keyValue + 2).trim()); + return; + } + const field = line.indexOf(':'); + if (field >= 0 && !line.startsWith('#') && !line.startsWith('NRRD')) { + const key = line.slice(0, field).trim(); + header.set(key, line.slice(field + 1).trim()); + } + }); + return header; +}; + +/** VolView writes with compression, so the data section arrives gzipped. */ +const readSegNrrd = (filePath: string) => { + const file = fs.readFileSync(filePath); + const raw = isGzip(file) ? zlib.gunzipSync(file) : file; + const split = raw.toString('latin1').indexOf('\n\n'); + const header = parseHeader(raw.toString('latin1', 0, split)); + + const data = raw.subarray(split + 2); + const voxels = isGzip(data) ? zlib.gunzipSync(data) : data; + return { header, voxels }; +}; + +const downloadSegmentGroup = async (stem: string) => { + const filePath = path.join(TEMP_DIR, `${stem}.seg.nrrd`); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + + await volViewPage.clickSaveSegmentsButton(); + const input = volViewPage.saveSegmentsFilenameInput; + await input.waitForDisplayed(); + await setValueVueInput(input, stem); + await volViewPage.saveSegmentsConfirmButton.click(); + + await waitForDownload(filePath, SAVE_TIMEOUT); + return readSegNrrd(filePath); +}; + +const editOnlySegment = async (name: string, red: number) => { + await $('[data-testid="segment-list"] button i[class~="mdi-pencil"]').click(); + const dialog = $('div[role="dialog"]'); + await dialog.waitForDisplayed(); + await setValueVueInput(dialog.$('.v-text-field input'), name); + await setValueVueInput(dialog.$('.v-color-picker-edit input'), String(red)); + await volViewPage.editLabelModalDoneButton.click(); + await dialog.waitForDisplayed({ reverse: true }); +}; + +describe('Segment identity under rename and recolor', function () { + this.timeout(120_000); + + it('leaves the exported voxels untouched while the name and color follow', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + await openAnnotationSegments(); + await waitForNamedSegments(); + + const stamp = Date.now(); + const before = await downloadSegmentGroup(`identity-before-${stamp}`); + expect(before.header.get('Segment0_Name')).toEqual('Segment 1'); + expect(before.voxels.some((voxel) => voxel !== 0)).toBe(true); + + await editOnlySegment('Tumor', 0); + + const after = await downloadSegmentGroup(`identity-after-${stamp}`); + expect(after.header.get('Segment0_Name')).toEqual('Tumor'); + expect(after.header.get('Segment0_Color')).not.toEqual( + before.header.get('Segment0_Color') + ); + + // Same label value, same voxels: the edits moved identity, not geometry. + expect(after.header.get('Segment0_LabelValue')).toEqual( + before.header.get('Segment0_LabelValue') + ); + expect(after.voxels.equals(before.voxels)).toBe(true); + }); +}); diff --git a/tests/specs/segment-overlap.e2e.ts b/tests/specs/segment-overlap.e2e.ts new file mode 100644 index 000000000..3ffe19aa1 --- /dev/null +++ b/tests/specs/segment-overlap.e2e.ts @@ -0,0 +1,206 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import JSZip from 'jszip'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { openUrls, waitForDownload } from './utils'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { + addSegment, + allowOverlap, + lockSegment, + openAnnotationSegments, + waitForNamedSegments, +} from './segmentationTestUtils'; + +const SAVE_TIMEOUT = 40_000; + +const overlapNotice = () => $('[data-testid="save-overlap-notice"]'); + +const isGzip = (buf: Buffer) => buf[0] === 0x1f && buf[1] === 0x8b; + +/** VolView writes with compression, so the data section arrives gzipped. */ +const readSegNrrd = (file: Buffer) => { + const raw = isGzip(file) ? zlib.gunzipSync(file) : file; + const split = raw.toString('latin1').indexOf('\n\n'); + + const header = new Map(); + raw + .toString('latin1', 0, split) + .split('\n') + .forEach((line) => { + const customSeparator = line.indexOf(':='); + if (customSeparator >= 0) { + header.set( + line.slice(0, customSeparator).trim(), + line.slice(customSeparator + 2).trim() + ); + return; + } + const separator = line.indexOf(':'); + if (separator >= 0 && !line.startsWith('#')) { + header.set( + line.slice(0, separator).trim(), + line.slice(separator + 1).trim() + ); + } + }); + + const data = raw.subarray(split + 2); + const voxels = isGzip(data) ? zlib.gunzipSync(data) : data; + const offsetsWhere = (keep: (voxel: number) => boolean) => + Array.from(voxels.entries()) + .filter(([, voxel]) => keep(voxel)) + .map(([offset]) => offset); + const labelValueOf = (name: string) => { + const [nameKey] = + [...header].find( + ([key, value]) => /^Segment\d+_Name$/.test(key) && value === name + ) ?? []; + return Number( + nameKey && header.get(nameKey.replace('_Name', '_LabelValue')) + ); + }; + return { + header, + foreground: offsetsWhere((voxel) => voxel !== 0), + segmentVoxels: (name: string) => { + const value = labelValueOf(name); + return offsetsWhere((voxel) => voxel === value); + }, + }; +}; + +const geometryFields = [ + 'type', + 'dimension', + 'sizes', + 'space', + 'space directions', + 'space origin', +] as const; + +const paintNewSegment = async (offsetX = 0) => { + await addSegment(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0], offsetX); +}; + +const openSaveDialog = async () => { + await volViewPage.clickSaveSegmentsButton(); + await volViewPage.saveSegmentsFilenameInput.waitForDisplayed(); +}; + +const saveAndUnzip = async (stem: string) => { + const filePath = path.join(TEMP_DIR, `${stem}.zip`); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + + await setValueVueInput(volViewPage.saveSegmentsFilenameInput, stem); + await volViewPage.saveSegmentsConfirmButton.click(); + + await waitForDownload(filePath, SAVE_TIMEOUT); + return JSZip.loadAsync(fs.readFileSync(filePath)); +}; + +const saveSingleLayer = async (stem: string) => { + const filePath = path.join(TEMP_DIR, `${stem}.seg.nrrd`); + fs.rmSync(filePath, { force: true }); + cleanuptotal.addCleanup(async () => fs.rmSync(filePath, { force: true })); + + await setValueVueInput(volViewPage.saveSegmentsFilenameInput, stem); + await volViewPage.saveSegmentsConfirmButton.click(); + await waitForDownload(filePath, SAVE_TIMEOUT); + return readSegNrrd(fs.readFileSync(filePath)); +}; + +describe('Painting one segment over another', function () { + this.timeout(120_000); + + beforeEach(async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + await openAnnotationSegments(); + await waitForNamedSegments(); + }); + + // One file carries one label per voxel, so the save announces an archive + // exactly when two segments hold a voxel in common. That notice is what makes + // overlap observable from the panel. + it('takes the voxels of an unlocked segment', async () => { + await paintNewSegment(); + await openSaveDialog(); + + await expect(overlapNotice()).not.toBeDisplayed(); + }); + + it('paints around a locked segment without taking or sharing its voxels', async () => { + await openSaveDialog(); + const baseline = await saveSingleLayer(`around-baseline-${Date.now()}`); + expect(baseline.foreground.length).toBeGreaterThan(0); + await lockSegment('Segment 1'); + // Half a loop over: the stroke crosses Segment 1 and runs past it. + await paintNewSegment(20); + await openSaveDialog(); + + await expect(overlapNotice()).not.toBeDisplayed(); + const saved = await saveSingleLayer(`around-${Date.now()}`); + expect(saved.segmentVoxels('Segment 1')).toEqual(baseline.foreground); + expect(saved.segmentVoxels('Segment 2').length).toBeGreaterThan(0); + }); + + it('shares the voxels of an unlocked segment while overlap is allowed', async () => { + await allowOverlap(); + await paintNewSegment(); + await openSaveDialog(); + + await expect(overlapNotice()).toBeDisplayed(); + }); + + it('saves overlapping segments losslessly, one file per layer', async () => { + await openSaveDialog(); + const baseline = await saveSingleLayer(`overlap-baseline-${Date.now()}`); + expect(baseline.foreground.length).toBeGreaterThan(0); + geometryFields.forEach((field) => { + expect(baseline.header.get(field)).toBeDefined(); + }); + expect(baseline.header.get('type')).toBe('unsigned char'); + await allowOverlap(); + await paintNewSegment(); + await openSaveDialog(); + + const stem = `overlap-${Date.now()}`; + const zip = await saveAndUnzip(stem); + + expect(Object.keys(zip.files).sort()).toEqual([ + `${stem}.seg.nrrd`, + `${stem}_layer1.seg.nrrd`, + ]); + + // Each layer carries one of the two segments, and carries its voxels: the + // overlap costs a file, not a segment. + const layers = await Promise.all( + [`${stem}.seg.nrrd`, `${stem}_layer1.seg.nrrd`].map(async (name) => + readSegNrrd(Buffer.from(await zip.files[name].async('arraybuffer'))) + ) + ); + + expect(layers.map((layer) => layer.header.get('Segment0_Name'))).toEqual([ + 'Segment 1', + 'Segment 2', + ]); + layers.forEach((layer) => { + expect(layer.header.get('Segment1_Name')).toBeUndefined(); + expect(layer.foreground).toEqual(baseline.foreground); + expect(geometryFields.map((field) => layer.header.get(field))).toEqual( + geometryFields.map((field) => baseline.header.get(field)) + ); + }); + }); +}); diff --git a/tests/specs/segment-preview-edits.e2e.ts b/tests/specs/segment-preview-edits.e2e.ts new file mode 100644 index 000000000..3ece2aa02 --- /dev/null +++ b/tests/specs/segment-preview-edits.e2e.ts @@ -0,0 +1,257 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { createHash } from 'node:crypto'; +import JSZip from 'jszip'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import AppPage, { setValueVueInput } from '../pageobjects/volview.page'; +import { + openAnnotationSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; +import { waitForDownload } from './utils'; + +const SIZE = 64; +const originalCount = 48 ** 3 - 38 ** 3; +const filledCount = 48 ** 3; + +function nrrd(data: Uint8Array) { + return Buffer.concat([ + Buffer.from( + `NRRD0005\ntype: unsigned char\ndimension: 3\nsizes: ${SIZE} ${SIZE} ${SIZE}\nspace: left-posterior-superior\nspace directions: (1,0,0) (0,1,0) (0,0,1)\nspace origin: (0,0,0)\nencoding: raw\n\n` + ), + data, + ]); +} + +async function openHollowCube() { + const parent = new Uint8Array(SIZE ** 3); + const mask = new Uint8Array(SIZE ** 3); + for (let k = 0; k < SIZE; k++) { + for (let j = 0; j < SIZE; j++) { + for (let i = 0; i < SIZE; i++) { + const offset = i + SIZE * (j + SIZE * k); + parent[offset] = i + j + k; + const inside = [i, j, k].every((v) => v >= 8 && v <= 55); + const hole = [i, j, k].every((v) => v >= 13 && v <= 50); + mask[offset] = Number(inside && !hole); + } + } + } + fs.writeFileSync(path.join(TEMP_DIR, 'preview-parent.nrrd'), nrrd(parent)); + const zip = new JSZip(); + zip.file('mask.nrrd', nrrd(mask)); + zip.file( + 'manifest.json', + JSON.stringify({ + version: '7.0.0', + dataSources: [{ id: 0, type: 'uri', uri: '/tmp/preview-parent.nrrd' }], + segments: [{ id: 'cube', name: 'Cube', color: [255, 0, 0, 255] }], + selectedSegment: 'cube', + segmentations: [ + { + id: 'segmentation', + name: 'Cube', + parentImage: '0', + order: ['mask'], + masks: [ + { + id: 'mask', + segmentId: 'cube', + representations: { + labelmap: { + path: 'mask.nrrd', + name: 'Cube', + extent: [0, 63, 0, 63, 0, 63], + }, + }, + }, + ], + }, + ], + }) + ); + fs.writeFileSync( + path.join(TEMP_DIR, 'preview.volview.zip'), + await zip.generateAsync({ type: 'nodebuffer' }) + ); + await AppPage.open('?urls=[tmp/preview.volview.zip]'); + await AppPage.waitForViews(); + await openAnnotationSegments(); + await waitForSegmentContent('Cube'); + await AppPage.activatePaint(); + const views = await AppPage.getViews2D(); + // A click with the selection tool establishes the slice view without painting. + await AppPage.selectTool('mdi-cursor-default'); + await views[0].$('canvas').click(); + await AppPage.activatePaint(); +} + +async function previewFill() { + await AppPage.processModeButton.waitForClickable(); + await AppPage.processModeButton.click(); + await AppPage.selectFillHolesProcess(); + await AppPage.fillHolesWholeVolumeButton.waitForClickable(); + await AppPage.fillHolesWholeVolumeButton.click(); + await AppPage.processPreviewButton.waitForClickable(); + await AppPage.processPreviewButton.click(); + await AppPage.processApplyButton.waitForClickable(); +} + +type PreviewChoice = 'original' | 'processed'; + +const previewCanvasState = () => + browser.execute(() => + Array.from( + document.querySelectorAll( + 'div[data-testid~="vtk-two-view"] canvas' + ) + ).map((canvas) => { + const copy = document.createElement('canvas'); + copy.width = canvas.width; + copy.height = canvas.height; + const context = copy.getContext('2d'); + context?.drawImage(canvas, 0, 0); + const pixels = context?.getImageData(0, 0, copy.width, copy.height).data; + if (!pixels) return null; + + const size = Math.min(copy.width, copy.height); + const centerX = copy.width / 2; + const centerY = copy.height / 2; + const samples = Array.from({ length: pixels.length / 4 }, (_, index) => { + const offset = index * 4; + const red = pixels[offset]; + const green = pixels[offset + 1]; + const blue = pixels[offset + 2]; + return { + dx: Math.abs((index % copy.width) - centerX) / size, + dy: Math.abs(Math.floor(index / copy.width) - centerY) / size, + visible: pixels[offset + 3] > 0 && red + green + blue > 15, + overlay: red > green + 35 && red > blue + 35, + }; + }); + const interior = samples.filter( + ({ dx, dy }) => dx > 0.07 && dx < 0.16 && dy > 0.07 && dy < 0.16 + ); + + return { + image: canvas.toDataURL(), + visiblePixels: samples.filter(({ visible }) => visible).length, + boundaryOverlayPixels: samples.filter( + ({ dx, dy, overlay }) => + overlay && + dx > 0.025 && + dy > 0.025 && + dx < 0.42 && + dy < 0.42 && + (dx > 0.22 || dy > 0.22) + ).length, + interiorOverlayRatio: + interior.filter(({ overlay }) => overlay).length / interior.length, + }; + }) + ); + +async function renderedPreview(choice: PreviewChoice, requireSelection = true) { + const button = + choice === 'original' + ? AppPage.processOriginalButton + : AppPage.processProcessedButton; + let state: Awaited> = []; + + await browser.waitUntil( + async () => { + state = await previewCanvasState(); + const expectedInterior = choice === 'processed' ? 0.25 : 0.02; + const interiorMatches = state.every((canvas) => + choice === 'processed' + ? canvas !== null && canvas.interiorOverlayRatio > expectedInterior + : canvas !== null && canvas.interiorOverlayRatio < expectedInterior + ); + return ( + (!requireSelection || (await AppPage.isPreviewToggleActive(button))) && + state.length > 0 && + state.every( + (canvas) => + canvas !== null && + canvas.visiblePixels > 100 && + canvas.boundaryOverlayPixels > 50 + ) && + interiorMatches + ); + }, + { timeoutMsg: `Expected ${choice} preview pixels and selection` } + ); + + return state.map((canvas) => + createHash('sha256') + .update(canvas?.image ?? '') + .digest('hex') + ); +} + +async function exportedVoxelCount(stem: string) { + const destination = path.join(TEMP_DIR, `${stem}.seg.nrrd`); + fs.rmSync(destination, { force: true }); + await AppPage.clickSaveSegmentsButton(); + await AppPage.saveSegmentsFilenameInput.waitForDisplayed(); + await setValueVueInput(AppPage.saveSegmentsFilenameInput, stem); + await AppPage.saveSegmentsConfirmButton.click(); + await waitForDownload(destination, 40000); + const file = fs.readFileSync(destination); + const split = file.indexOf('\n\n'); + const bytes = file.subarray(split + 2); + const data = + bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes; + return data.reduce((count, value) => count + Number(value !== 0), 0); +} + +describe('Segment preview ownership', () => { + beforeEach(openHollowCube); + + it('exports committed content during a preview and saves the result after Apply', async () => { + expect(await exportedVoxelCount('before-preview')).toBe(originalCount); + await previewFill(); + expect(await exportedVoxelCount('during-preview')).toBe(originalCount); + await expect(AppPage.processApplyButton).not.toBeDisplayed(); + await AppPage.processPreviewButton.waitForClickable(); + await AppPage.processPreviewButton.click(); + await AppPage.processApplyButton.waitForClickable(); + await AppPage.processOriginalButton.click(); + await AppPage.processApplyButton.click(); + expect(await exportedVoxelCount('applied-preview')).toBe(filledCount); + }); + + it('keeps named preview choices idempotent for pointer and keyboard activation', async () => { + const initial = await renderedPreview('original', false); + await previewFill(); + const processed = await renderedPreview('processed'); + expect(processed).not.toEqual(initial); + + await AppPage.processProcessedButton.click(); + expect(await renderedPreview('processed')).toEqual(processed); + + await AppPage.processOriginalButton.execute((element) => element.focus()); + await browser.keys('Enter'); + const original = await renderedPreview('original'); + expect(original).toEqual(initial); + + await browser.keys('Enter'); + expect(await renderedPreview('original')).toEqual(original); + + await AppPage.processProcessedButton.click(); + expect(await renderedPreview('processed')).toEqual(processed); + }); + + it('cancels a preview before a brush stroke and preserves the new stroke', async () => { + await previewFill(); + await AppPage.selectTool('mdi-cursor-default'); + await AppPage.activatePaint(); + const views = await AppPage.getViews2D(); + await AppPage.paintStrokeOnView(views[0]); + await expect(AppPage.processApplyButton).not.toBeDisplayed(); + const count = await exportedVoxelCount('paint-after-preview'); + expect(count).toBeGreaterThan(originalCount); + expect(count).toBeLessThan(filledCount); + }); +}); diff --git a/tests/specs/segment-shared-type.e2e.ts b/tests/specs/segment-shared-type.e2e.ts new file mode 100644 index 000000000..9fda4f9aa --- /dev/null +++ b/tests/specs/segment-shared-type.e2e.ts @@ -0,0 +1,98 @@ +import { MINIMAL_DICOM, ONE_CT_SLICE_DICOM } from '../datasets'; +import { openUrls } from './utils'; +import { volViewPage } from '../pageobjects/volview.page'; +import { + addSegment, + openAnnotationSegments, + renameSegment, + segmentColor, + segmentNames, + selectSegment, +} from './segmentationTestUtils'; + +const volumeCards = () => $$('.volume-card'); + +const activeCardIndex = () => + volumeCards().findIndex(async (card) => + ((await card.getAttribute('class')) ?? '').includes('volume-card-active') + ); + +// The module panel keeps every module mounted, so a volume card is only +// clickable while the Data module is the one on screen. +const showImage = async (index: number) => { + await $('button[data-testid="module-tab-Data"]').click(); + const cards = await volumeCards(); + await cards[index].scrollIntoView(); + await cards[index].click(); + await browser.waitUntil(async () => (await activeCardIndex()) === index, { + timeoutMsg: `Expected volume card ${index} to become the viewed image`, + }); + + await openAnnotationSegments(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.waitForLoadingIndicator(views2D[0]); +}; + +const paintOnViewedImage = async () => { + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); +}; + +// The panel lists the shared registry, so the rows are the same on every +// image; what differs is which of them this image has a mask for. +const expectSegments = async (expected: string[]) => { + await browser.waitUntil( + async () => { + const names = await segmentNames(); + return ( + names.length === expected.length && + names.every((name, index) => name === expected[index]) + ); + }, + { timeoutMsg: `Expected the segment list to show ${expected.join(', ')}` } + ); + expect(await segmentNames()).toEqual(expected); +}; + +describe('Segment identity across images', function () { + this.timeout(240_000); + + it('offers one type on every image and paints it into each', async () => { + await openUrls([ONE_CT_SLICE_DICOM, MINIMAL_DICOM]); + await browser.waitUntil(async () => (await volumeCards().length) === 2, { + timeoutMsg: 'Expected both volume cards to appear', + }); + + const first = await activeCardIndex(); + expect(first).toBeGreaterThanOrEqual(0); + const second = first === 0 ? 1 : 0; + + await volViewPage.activatePaint(); + await paintOnViewedImage(); + await openAnnotationSegments(); + await expectSegments(['Segment 1']); + await addSegment(); + await renameSegment('Segment 2', 'Tumor'); + await selectSegment('Tumor'); + const tumorColor = await segmentColor('Tumor'); + + // The registry is image-independent, so viewing the other image offers + // exactly the same types, and creates nothing. + await showImage(second); + await expectSegments(['Segment 1', 'Tumor']); + expect(await volViewPage.getNotificationsCount()).toEqual(0); + + // Painting there writes into this image's own mask for the same type. + await paintOnViewedImage(); + await expectSegments(['Segment 1', 'Tumor']); + expect(await segmentColor('Tumor')).toEqual(tumorColor); + + await renameSegment('Tumor', 'Tumor A'); + await expectSegments(['Segment 1', 'Tumor A']); + + await showImage(first); + await expectSegments(['Segment 1', 'Tumor A']); + await paintOnViewedImage(); + await expectSegments(['Segment 1', 'Tumor A']); + }); +}); diff --git a/tests/specs/segment-shortcuts.e2e.ts b/tests/specs/segment-shortcuts.e2e.ts new file mode 100644 index 000000000..7faaf91a2 --- /dev/null +++ b/tests/specs/segment-shortcuts.e2e.ts @@ -0,0 +1,138 @@ +import * as path from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { openUrls, waitForFileExists, SESSION_SAVE_TIMEOUT } from './utils'; +import { openAnnotationSegments } from './segmentationTestUtils'; + +const selected = () => + $('[data-testid="segment-list"] .item-row[aria-current="true"]'); +const segmentRow = (name: string) => + $(`[data-testid="segment-list"] .item-row[aria-label="${name}"]`); +const titles = () => + browser.execute(() => + Array.from( + document.querySelectorAll('[data-testid="segment-list"] .item-row'), + (row) => row.getAttribute('aria-label') + ) + ); + +// Native drag events exercise the same handle/drop handlers without depending +// on a browser's drag-distance threshold or autoscroll timing. +const dragBefore = async (name: string, target: string) => { + const row = await segmentRow(name); + const destination = await segmentRow(target); + const handle = await row.$('.reorder-handle'); + await browser.execute( + (source, to) => { + const dataTransfer = new DataTransfer(); + const bounds = to.getBoundingClientRect(); + source.dispatchEvent( + new DragEvent('dragstart', { bubbles: true, dataTransfer }) + ); + to.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer, + clientY: bounds.top + 1, + }) + ); + }, + handle, + destination + ); + await expect(destination).toHaveAttribute('data-drop-position', 'before'); + expect( + await browser.execute( + (element) => getComputedStyle(element).borderTopColor, + destination + ) + ).not.toBe('rgba(0, 0, 0, 0)'); + await browser.execute( + (source, to) => { + const dataTransfer = new DataTransfer(); + to.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + clientY: to.getBoundingClientRect().top + 1, + }) + ); + source.dispatchEvent( + new DragEvent('dragend', { bubbles: true, dataTransfer }) + ); + }, + handle, + destination + ); +}; + +describe('Segment shortcuts and ordering', () => { + it('selects the first ten rows, follows reordered rows keeps typed digits in fields and restores saved order', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await openAnnotationSegments(); + for (let i = 0; i < 11; i++) { + await $('[data-testid="segment-list"] .create-row').click(); + await expect(segmentRow(`Segment ${i + 1}`)).toExist(); + } + + for (let i = 1; i <= 10; i++) { + await browser.keys(String(i % 10)); + await expect(selected()).toHaveAttribute('aria-label', `Segment ${i}`); + await expect((await segmentRow(`Segment ${i}`)).$('kbd')).toHaveText( + String(i % 10) + ); + } + await expect((await segmentRow('Segment 11')).$('kbd')).not.toExist(); + + await dragBefore('Segment 11', 'Segment 1'); + await browser.waitUntil(async () => (await titles())[0] === 'Segment 11'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 10'); + await browser.keys('1'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 11'); + await browser.keys('0'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 9'); + await expect((await segmentRow('Segment 10')).$('kbd')).not.toExist(); + + const firstHandle = (await segmentRow('Segment 11')).$('.reorder-handle'); + await firstHandle.click(); + await browser.keys(['Alt', 'ArrowDown']); + await browser.waitUntil(async () => (await titles())[1] === 'Segment 11'); + await browser.keys('1'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + + await (await segmentRow('Segment 1')) + .$('[data-testid="edit-segment-button"]') + .click(); + const input = $('div[role="dialog"] .v-text-field input'); + await input.click(); + await input.addValue('1234567890'); + await expect(input).toHaveValue('Segment 11234567890'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + await volViewPage.editLabelModalDoneButton.click(); + await expect(selected()).toHaveAttribute( + 'aria-label', + 'Segment 11234567890' + ); + const order = await titles(); + const session = await volViewPage.saveSession(); + await waitForFileExists(path.join(TEMP_DIR, session), SESSION_SAVE_TIMEOUT); + await volViewPage.open(`?urls=[tmp/${session}]`); + await volViewPage.waitForViews(); + await openAnnotationSegments(); + await browser.waitUntil( + async () => (await titles()).length === order.length + ); + expect(await titles()).toEqual(order); + await expect(selected()).toHaveAttribute( + 'aria-label', + 'Segment 11234567890' + ); + await browser.keys('2'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 11'); + await browser.keys('0'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 9'); + }); +}); diff --git a/tests/specs/segment-tooltips.e2e.ts b/tests/specs/segment-tooltips.e2e.ts new file mode 100644 index 000000000..df2093212 --- /dev/null +++ b/tests/specs/segment-tooltips.e2e.ts @@ -0,0 +1,80 @@ +import type { ChainablePromiseElement } from 'webdriverio'; +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { moveTo } from './annotationTestUtils'; +import { + openAnnotationSegments, + renameSegment, + segmentRow, + waitForSegmentContent, +} from './segmentationTestUtils'; +import { openUrls } from './utils'; + +const descriptionOf = async (element: ChainablePromiseElement) => { + const id = await element.getAttribute('aria-describedby'); + expect(id).toBeTruthy(); + return $(`[id="${id}"]`); +}; + +describe('Segment tooltips', () => { + it('shows row descriptions on hover and explains locked controls on keyboard focus', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.activatePaint(); + const views = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views[0]); + await openAnnotationSegments(); + await waitForSegmentContent('Segment 1'); + + const name = 'A segment name that is longer than the available row width'; + await renameSegment('Segment 1', name); + const row = await segmentRow(name); + const title = await row.$('.v-list-item-title'); + await title.moveTo(); + const titleTooltip = await descriptionOf(title); + await expect(titleTooltip).toBeDisplayed(); + await expect(titleTooltip).toHaveText(name); + await moveTo(10, 10); + await expect(titleTooltip).not.toBeDisplayed(); + + const lock = await row.$('button[aria-label^="Lock "]'); + await lock.moveTo(); + const lockTooltip = await descriptionOf(lock); + await expect(lockTooltip).toBeDisplayed(); + await expect(lockTooltip).toHaveText( + 'Lock. Painting other segments goes around it, or overlaps it with Allow Overlap on.' + ); + await lock.click(); + await moveTo(10, 10); + + await browser.keys(['Shift', 'Tab']); + const edit = await row.$('[data-testid="edit-segment-button"]'); + const editActivator = await edit.$('..'); + await expect(edit).toBeDisabled(); + expect( + await browser.execute( + (element) => document.activeElement === element, + await editActivator + ) + ).toBe(true); + const editTooltip = await descriptionOf(editActivator); + await expect(editTooltip).toBeDisplayed(); + await expect(editTooltip).toHaveText('Unlock this segment to edit it'); + + await browser.keys(['Shift', 'Tab']); + await browser.keys(['Shift', 'Tab']); + const color = await row.$('[data-testid="segment-color-button"]'); + const colorActivator = await color.$('..'); + await expect(color).toBeDisabled(); + expect( + await browser.execute( + (element) => document.activeElement === element, + await colorActivator + ) + ).toBe(true); + const colorTooltip = await descriptionOf(colorActivator); + await expect(colorTooltip).toBeDisplayed(); + await expect(colorTooltip).toHaveText( + 'Unlock this segment to change its color' + ); + }); +}); diff --git a/tests/specs/segmentation-config-migration.e2e.ts b/tests/specs/segmentation-config-migration.e2e.ts new file mode 100644 index 000000000..2b1e5acfa --- /dev/null +++ b/tests/specs/segmentation-config-migration.e2e.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import JSZip from 'jszip'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { writeConfigImages } from './configurationImage'; +import { + openAnnotationSegments, + segmentNames, + waitForNamedSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; +import { + writeManifestToFile, + waitForDownload, + SESSION_SAVE_TIMEOUT, +} from './utils'; + +const openConfigured = async (config: unknown) => { + const stem = 'segmentation-config-case'; + writeConfigImages(stem); + await writeManifestToFile(config, `${stem}.json`); + await volViewPage.open( + `?urls=[tmp/${stem}.json,tmp/${stem}.nrrd,tmp/${stem}.seg.nrrd]` + ); + await volViewPage.waitForViews(); + await openAnnotationSegments(); + await waitForSegmentContent('Matched mask'); +}; + +const labels = { rulerLabels: { Configured: { color: '#ff0000' } } }; + +describe('Segmentation configuration migration', () => { + for (const key of ['segmentationSaveFormat', 'segmentGroupSaveFormat']) { + it(`saves session masks as ${key} specifies and migrates label configuration`, async () => { + await openConfigured({ + io: { [key]: 'mha', segmentationExtension: 'seg' }, + labels, + }); + await waitForNamedSegments(); + expect(await segmentNames()).toContain('Configured'); + const filename = await volViewPage.saveSession(); + const path = join(TEMP_DIR, filename); + await waitForDownload(path, SESSION_SAVE_TIMEOUT); + const zip = await JSZip.loadAsync(readFileSync(path)); + const masks = Object.keys(zip.files).filter( + (name) => name.startsWith('segmentations/') && !zip.files[name].dir + ); + expect(masks.length).toBeGreaterThan(0); + expect(masks.every((name) => name.endsWith('.mha'))).toBe(true); + }); + } + + it('uses canonical segments when legacy labels are also supplied', async () => { + await openConfigured({ + io: { segmentationExtension: 'seg' }, + labels, + segments: { Canonical: { color: '#00ff00' } }, + }); + const names = await segmentNames(); + expect(names).toContain('Canonical'); + expect(names).not.toContain('Configured'); + }); +}); diff --git a/tests/specs/segmentation-extension.e2e.ts b/tests/specs/segmentation-extension.e2e.ts new file mode 100644 index 000000000..99a5d4f35 --- /dev/null +++ b/tests/specs/segmentation-extension.e2e.ts @@ -0,0 +1,64 @@ +import { writeConfigImages } from './configurationImage'; +import { volViewPage } from '../pageobjects/volview.page'; +import { writeManifestToFile } from './utils'; +import { + openAnnotationSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; + +const openWithIo = async (io: Record) => { + writeConfigImages('extension-case'); + const configName = 'segmentation-extension-config.json'; + await writeManifestToFile({ io }, configName); + await volViewPage.open( + `?urls=[tmp/${configName},tmp/extension-case.nrrd,tmp/extension-case.seg.nrrd]` + ); + await volViewPage.waitForViews(); +}; + +describe('Segmentation filename configuration', () => { + for (const key of ['segmentationExtension', 'segmentGroupExtension']) { + it(`associates mask content using ${key}`, async () => { + await openWithIo({ [key]: 'seg' }); + await openAnnotationSegments(); + await waitForSegmentContent('Matched mask'); + if (key === 'segmentGroupExtension') { + await volViewPage.notifications.click(); + await $('.message-center .v-expansion-panel-title').click(); + await expect($('.v-overlay--active')).toHaveText( + expect.stringContaining( + 'io.segmentGroupExtension was migrated to io.segmentationExtension' + ) + ); + } else { + expect(await volViewPage.getNotificationsCount()).toBe(0); + } + }); + } + + it('keeps the mask as an ordinary image when matching is disabled', async () => { + await openWithIo({ segmentGroupExtension: '' }); + await $('button[data-testid="module-tab-Data"]').click(); + const mask = $('.v-card:has([title="extension-case.seg.nrrd"])'); + await mask.waitForDisplayed(); + await mask.$('button.dataset-menu').click(); + await expect($('.v-overlay--active')).toHaveText( + expect.stringContaining('Add as segmentation') + ); + }); + + it('reports conflicting old and new configuration names', async () => { + await openWithIo({ + segmentationExtension: 'seg', + segmentGroupExtension: 'mask', + }); + await volViewPage.waitForNotification(); + await volViewPage.notifications.click(); + await $('.message-center .v-expansion-panel-title').click(); + await expect($('.v-overlay--active')).toHaveText( + expect.stringContaining( + 'io.segmentGroupExtension conflicts with io.segmentationExtension' + ) + ); + }); +}); diff --git a/tests/specs/segmentationTestUtils.ts b/tests/specs/segmentationTestUtils.ts new file mode 100644 index 000000000..435365106 --- /dev/null +++ b/tests/specs/segmentationTestUtils.ts @@ -0,0 +1,187 @@ +import type { ChainablePromiseElement } from 'webdriverio'; +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; + +const SEGMENT_LIST = '[data-testid="segment-list"]'; +const SHAPE_ROW = '[data-testid="segment-shape-row"]'; + +// Only a row standing for a real item carries a title; the trailing "create" +// row does not. +const namesIn = (root: string) => + $$(`${root} .item-row .v-list-item-title`).map((title) => title.getText()); + +const findRow = async (root: string, name: string) => { + const rows = await $$(`${root} .item-row`); + for (const row of rows) { + const title = await row.$('.v-list-item-title'); + if ((await title.isExisting()) && (await title.getText()) === name) { + return row; + } + } + return undefined; +}; + +// Rows arrive after the image renders: an import or restore mints its segments +// once the labelmap is read, so a row is waited for rather than looked up once. +const rowNamed = async (root: string, name: string) => { + const row = await browser.waitUntil(() => findRow(root, name), { + timeoutMsg: `No row named "${name}" under ${root}`, + }); + return row as NonNullable; +}; + +const dotColor = async (root: string, name: string) => { + const row = await rowNamed(root, name); + const dot = await row.$('.color-dot'); + return (await dot.getCSSProperty('background-color')).value; +}; + +const addRow = async (root: string) => { + const before = await namesIn(root); + await $(`${root} .create-row`).click(); + await browser.waitUntil( + async () => (await namesIn(root)).length === before.length + 1, + { timeoutMsg: `Expected the create row to add an item under ${root}` } + ); +}; + +const editDialog = () => $('div[role="dialog"]'); + +const renameInOpenDialog = async (to: string) => { + const dialog = editDialog(); + await dialog.waitForDisplayed(); + // Name is the first text field in the editor both lists open. + await setValueVueInput(dialog.$('.v-text-field input'), to); + await volViewPage.editLabelModalDoneButton.click(); + await dialog.waitForDisplayed({ reverse: true }); +}; + +export const segmentNames = () => namesIn(SEGMENT_LIST); + +export const segmentRow = (name: string) => rowNamed(SEGMENT_LIST, name); + +/** + * Waits for mask or shape content, which arrives after the catalog name: a + * restore lists its segments, then reads their masks. How long that takes is + * the load's business, so this waits for the list to say the load is over + * instead of giving the content a time of its own. + */ +export const waitForSegmentContent = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + await browser.waitUntil( + async () => (await $(SEGMENT_LIST).getAttribute('aria-busy')) !== 'true', + { timeoutMsg: 'Expected the scene to finish loading' } + ); + await row.$('button[data-testid="reveal-segment-button"]').waitForEnabled(); +}; + +export const segmentColor = (name: string) => dotColor(SEGMENT_LIST, name); + +export const addSegment = () => addRow(SEGMENT_LIST); + +export const selectSegment = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + // Clicking the title rather than the row keeps the hit away from the + // right-aligned controls on a narrow sidebar. + await row.$('.v-list-item-title').click(); +}; + +export const selectedSegmentName = () => + $( + `${SEGMENT_LIST} .item-row.v-list-item--active .v-list-item-title` + ).getText(); + +/** Page-coordinate top of the Segments list, for asserting it has not moved. */ +export const segmentListTop = async () => + (await $(SEGMENT_LIST).getLocation()).y; + +const deleteIn = async (root: string, name: string) => { + const row = await rowNamed(root, name); + await row.$('button i[class~="mdi-delete"]').click(); + await browser.waitUntil(async () => !(await namesIn(root)).includes(name), { + timeoutMsg: `Expected "${name}" to leave ${root}`, + }); +}; + +export const deleteSegment = (name: string) => deleteIn(SEGMENT_LIST, name); + +export const revealSegment = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + const button = await row.$('button[data-testid="reveal-segment-button"]'); + await button.waitForClickable(); + await button.click(); +}; + +const renameIn = async (root: string, from: string, to: string) => { + const row = await rowNamed(root, from); + await row.$('button[data-testid="edit-segment-button"]').click(); + await renameInOpenDialog(to); + await browser.waitUntil(async () => (await namesIn(root)).includes(to), { + timeoutMsg: `Expected ${root} to show "${to}"`, + }); +}; + +export const renameSegment = (from: string, to: string) => + renameIn(SEGMENT_LIST, from, to); + +/** The Segments list is pinned at the top of the Annotations panel. */ +export const openAnnotationSegments = async () => { + await volViewPage.annotationsModuleTab.click(); + await $(SEGMENT_LIST).waitForDisplayed(); + // Selecting a tool slides this panel in, and a click aimed at a row that is + // still moving lands on its neighbour. + await $(SEGMENT_LIST).waitForStable(); +}; + +export const openSegmentShapes = async () => { + await volViewPage.annotationsModuleTab.click(); + const section = $('[data-testid="measurements-section"]'); + await section.waitForClickable(); + if ((await section.getAttribute('aria-expanded')) === 'false') { + await section.click(); + } + await $(SHAPE_ROW).waitForDisplayed(); +}; + +/** One line per shape: where it sits, and a ruler's length. */ +export const shapeRowTexts = () => $$(SHAPE_ROW).map((row) => row.getText()); + +export const waitForNamedSegments = async () => { + await $(SEGMENT_LIST).waitForDisplayed(); + // A row renders before its title does, so a name-less row is not yet a + // segment the caller can read. + await browser.waitUntil( + async () => { + const names = await segmentNames(); + return names.length >= 1 && names.every((name) => name.length > 0); + }, + { timeoutMsg: 'Expected the viewed image to have a named segment' } + ); +}; + +/** Locks a segment: a later stroke of another segment goes around it. */ +export const lockSegment = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + await row.$('button i[class~="mdi-lock-open"]').click(); + await row.$('button i[class~="mdi-lock"]').waitForExist({ + timeoutMsg: `Expected "${name}" to show as locked`, + }); +}; + +/** Turns on Allow Overlap, opening the Paint panel it lives in if it was closed. */ +export const allowOverlap = async () => { + const panel = $('button.v-expansion-panel-title*=Paint'); + if ((await panel.getAttribute('aria-expanded')) !== 'true') + await panel.click(); + const toggle = $('input[aria-label="Allow Overlap"]'); + await toggle.waitForExist(); + await toggle.execute((element) => element.focus()); + await browser.keys(' '); + await expect(toggle).toBeSelected(); +}; + +/** The tooltip an element's hover or focus is showing. */ +export const tooltipOf = async (element: ChainablePromiseElement) => { + const id = await element.getAttribute('aria-describedby'); + expect(id).toBeTruthy(); + return $(`[id="${id}"]`); +}; diff --git a/tests/specs/select-annotation-at-press.e2e.ts b/tests/specs/select-annotation-at-press.e2e.ts index ac261be8c..a711f9b3f 100644 --- a/tests/specs/select-annotation-at-press.e2e.ts +++ b/tests/specs/select-annotation-at-press.e2e.ts @@ -1,6 +1,13 @@ import { type ChainablePromiseElement } from 'webdriverio'; import AppPage from '../pageobjects/volview.page'; -import { clickAt, setupTest, waitForCircleCount } from './annotationTestUtils'; +import { + clickAt, + nudgeTo, + pressAtPointer, + setupTest, + teleportTo, + waitForCircleCount, +} from './annotationTestUtils'; // BoundingRectangle.vue draws this around the selected annotation const getSelectionRectCount = async (axialView: ChainablePromiseElement) => { @@ -18,51 +25,6 @@ const waitForSelectionRectCount = ( { timeout: 5000, timeoutMsg } ); -// One input source held across action chains, so a press can land exactly where -// an earlier chain left the pointer. Chains that keep it perform without -// releasing actions, as releasing resets the pointer to the viewport origin. -const HOVERING_MOUSE = 'hovering-mouse'; -const hoveringMouse = () => browser.action('pointer', { id: HOVERING_MOUSE }); - -// A move with a duration is interpolated into a stream of pointer moves. A -// zero duration dispatches exactly one, which is what teleportTo relies on. -const INSTANT = 0; -const NUDGE_PX = 2; - -// Two moves in one chain, so the one landing on (x, y) is never the first move -// after an idle period, which vtk.js reports as StartMouseMove and the widget -// manager ignores. The pick therefore runs at (x, y). -const nudgeTo = (x: number, y: number) => - hoveringMouse() - .move({ - duration: INSTANT, - x: Math.round(x) + NUDGE_PX, - y: Math.round(y) + NUDGE_PX, - }) - .move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) }) - .perform(true); - -// vtk.js reports the first pointer move after ~200ms of stillness as -// StartMouseMove, which the widget manager does not subscribe to. A single move -// after that idle therefore relocates the pointer while leaving the widget -// manager's pick standing at the old position. -const IDLE_MS = 400; - -const teleportTo = async (x: number, y: number) => { - await browser.pause(IDLE_MS); - await hoveringMouse() - .move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) }) - .perform(true); -}; - -const pressAtPointer = () => hoveringMouse().down().up().perform(true); - -const placeRectangle = async (cx: number, cy: number, halfSize: number) => { - await AppPage.selectTool('mdi-vector-square'); - await clickAt(cx - halfSize, cy - halfSize); - await clickAt(cx + halfSize, cy + halfSize); -}; - // Hovers the handle and presses until the annotation selects, which proves the // widget manager resolved a pick there and left it as its standing selection. const hoverAndSelect = async ( @@ -83,17 +45,34 @@ const hoverAndSelect = async ( } ); -describe('Selection picks at the press position', () => { - it('does not select an annotation the pointer left without a tracked move', async () => { - const { axialView, centerX, centerY } = await setupTest(); +const setupSelectedRectangle = async () => { + const context = await setupTest(); + const { axialView, centerX, centerY } = context; + await AppPage.selectTool('mdi-vector-square'); + await clickAt(centerX - 80, centerY - 80); + await clickAt(centerX + 80, centerY + 80); + await waitForCircleCount(axialView, 2, 'Rectangle should have two handles'); + await AppPage.selectTool('mdi-cursor-default'); + + const shape = axialView.$('svg rect:not([stroke="lightgray"])'); + const bounds = () => + Promise.all( + ['x', 'y', 'width', 'height'].map((name) => shape.getAttribute(name)) + ); + const before = await bounds(); + await hoverAndSelect(axialView, centerX - 80, centerY - 80); + return { ...context, bounds, before }; +}; - const handleX = centerX - 80; - const handleY = centerY - 80; - await placeRectangle(centerX, centerY, 80); - await waitForCircleCount(axialView, 2, 'Rectangle should have two handles'); +describe('Selection picks at the press position', () => { + afterEach(async () => { + // Selection can still update after another press handler throws. + expect(await AppPage.getNotificationsCount()).toBe(0); + }); - await AppPage.selectTool('mdi-cursor-default'); - await hoverAndSelect(axialView, handleX, handleY); + it('does not select an annotation the pointer left without a tracked move', async () => { + const { axialView, centerX, centerY, bounds, before } = + await setupSelectedRectangle(); // Empty image area, well clear of both handles and the rectangle outline await teleportTo(centerX + 140, centerY - 140); @@ -104,19 +83,15 @@ describe('Selection picks at the press position', () => { 0, 'Pressing on empty space should deselect, not act on the pick left behind at the handle' ); + expect(await bounds()).toEqual(before); }); // Control for the case above: same annotation, same press position, only the // move onto empty space is one the widget manager tracks. Deselecting here // shows the press does reach the view and that nothing is pickable there. it('deselects when the move onto empty space is tracked', async () => { - const { axialView, centerX, centerY } = await setupTest(); - - await placeRectangle(centerX, centerY, 80); - await waitForCircleCount(axialView, 2, 'Rectangle should have two handles'); - - await AppPage.selectTool('mdi-cursor-default'); - await hoverAndSelect(axialView, centerX - 80, centerY - 80); + const { axialView, centerX, centerY, bounds, before } = + await setupSelectedRectangle(); await nudgeTo(centerX + 140, centerY - 140); await pressAtPointer(); @@ -126,5 +101,56 @@ describe('Selection picks at the press position', () => { 0, 'Pressing on empty space should deselect the rectangle' ); + expect(await bounds()).toEqual(before); }); + + for (const [name, icon] of [ + ['rectangle', 'mdi-vector-square'], + ['ruler', 'mdi-ruler'], + ]) { + it(`adjusts a ${name} handle after switching back from paint`, async () => { + const { axialView, centerX, centerY } = await setupTest(); + const handleX = centerX - 80; + const handleY = centerY - 80; + await AppPage.selectTool(icon); + await clickAt(handleX, handleY); + await clickAt(centerX + 80, centerY + 80); + await waitForCircleCount( + axialView, + 2, + 'Placed annotation should have two handles' + ); + await AppPage.activatePaint(); + await AppPage.selectTool(icon); + const firstHandle = axialView.$('svg circle'); + const start = await Promise.all( + ['cx', 'cy'].map((axis) => firstHandle.getAttribute(axis)) + ); + await nudgeTo(handleX, handleY); + await browser + .action('pointer') + .move({ x: Math.round(handleX), y: Math.round(handleY) }) + .down() + .move({ + x: Math.round(handleX + 35), + y: Math.round(handleY + 20), + duration: 400, + }) + .up() + .perform(); + await waitForCircleCount( + axialView, + 2, + 'Dragging should keep the same annotation' + ); + expect(Number(await firstHandle.getAttribute('cx'))).toBeCloseTo( + Number(start[0]) + 35, + 0 + ); + expect(Number(await firstHandle.getAttribute('cy'))).toBeCloseTo( + Number(start[1]) + 20, + 0 + ); + }); + } }); diff --git a/tests/specs/session-large-uri-base.e2e.ts b/tests/specs/session-large-uri-base.e2e.ts index f76071f7b..186acbcdd 100644 --- a/tests/specs/session-large-uri-base.e2e.ts +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -6,6 +6,7 @@ import { cleanuptotal } from 'wdio-cleanuptotal-service'; import { volViewPage } from '../pageobjects/volview.page'; import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; import { writeManifestToFile } from './utils'; +import { openAnnotationSegments, segmentNames } from './segmentationTestUtils'; const writeBufferToFile = async (data: Buffer, fileName: string) => { const filePath = path.join(TEMP_DIR, fileName); @@ -21,7 +22,8 @@ const createNiftiGz = ( dimY: number, dimZ: number, datatype: number, - bitpix: number + bitpix: number, + foreground = false ) => { const bytesPerVoxel = bitpix / 8; const header = Buffer.alloc(352); @@ -58,6 +60,9 @@ const createNiftiGz = ( header.write('n+1\0', 344, 'binary'); const imageData = Buffer.alloc(dimX * dimY * dimZ * bytesPerVoxel); + // The labelmap needs actual content to distinguish decoded mask storage + // from the segment catalog published at the start of restoration. + if (foreground) imageData[imageData.length / 2] = 1; return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); }; @@ -76,6 +81,20 @@ const createSessionZip = async ( }, ], datasets: [{ id: '0', dataSourceId: 0 }], + // Heap growth is the regression trigger; a single slice avoids allocating + // a second large GPU texture for volume rendering in headless browsers. + layout: { direction: 'row', items: [{ type: 'slot', slotIndex: 0 }] }, + layoutSlots: ['axial'], + activeView: 'axial', + viewByID: { + axial: { + id: 'axial', + type: '2D', + name: 'Axial', + dataID: '0', + options: { orientation: 'Axial' }, + }, + }, segmentGroups: [ { id: 'seg-1', @@ -111,7 +130,7 @@ const createSessionZip = async ( * A .volview.zip session with a large Float32 URI-based base image and an * embedded .nii.gz labelmap. The import pipeline loads the base image * through the shared ITK-wasm worker, growing the WASM heap past 2GB. - * Then segmentGroupStore.deserialize() calls readImage() for the embedded + * Then segmentationStore.deserialize() calls readImage() for the embedded * .nii.gz labelmap on the same worker. * * The .nii.gz format is critical: .vti labelmaps use a separate JS @@ -138,7 +157,7 @@ describe('Session with large URI base and nii.gz labelmap', function () { ); // UInt8 labelmap same dimensions = 256MB raw, embedded in session ZIP - const labelmapNiftiGz = createNiftiGz(1024, 1024, 256, 2, 8); + const labelmapNiftiGz = createNiftiGz(1024, 1024, 256, 2, 8, true); const sessionZip = await createSessionZip(baseFileName, labelmapNiftiGz); await writeBufferToFile(sessionZip, sessionFileName); @@ -166,32 +185,20 @@ describe('Session with large URI base and nii.gz labelmap', function () { ); await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); - // Open the segment groups panel so the list renders in the DOM - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const segmentGroupsTab = await $('button.v-tab*=Segment Groups'); - await segmentGroupsTab.waitForClickable(); - await segmentGroupsTab.click(); + // Open the segments panel so the list renders in the DOM + await openAnnotationSegments(); - // Wait for the labelmap readImage to either succeed (segment group - // appears) or fail (RangeError in console OR error notification). - // The deserialization is async and finishes after views render. - const notifsBefore = await volViewPage.getNotificationsCount(); + // This session contains no shapes. Reveal can only become enabled once + // the embedded labelmap has decoded and its foreground mask is attached. + const reveal = $( + '[data-testid="segment-list"] button[data-testid="reveal-segment-button"]' + ); await browser.waitUntil( async () => { if (rangeErrors.length > 0) return true; - try { - const notifs = await volViewPage.getNotificationsCount(); - if (notifs > notifsBefore) return true; - } catch { - // badge may not exist yet - } - const segmentGroups = await $$('.segment-group-list .v-list-item'); - return (await segmentGroups.length) >= 1; + if ((await volViewPage.getNotificationsCount()) > 0) return true; + return (await reveal.isExisting()) && (await reveal.isEnabled()); }, { timeout: DOWNLOAD_TIMEOUT * 3, @@ -200,6 +207,9 @@ describe('Session with large URI base and nii.gz labelmap', function () { ); expect(rangeErrors).toEqual([]); + expect(await volViewPage.getNotificationsCount()).toBe(0); + expect(await segmentNames()).toEqual(['Label 1']); + expect(await reveal.isEnabled()).toBe(true); } finally { browser.off('log.entryAdded', onLogEntry); } diff --git a/tests/specs/session-state-lifecycle.e2e.ts b/tests/specs/session-state-lifecycle.e2e.ts index 2acc180e8..5d56fc117 100644 --- a/tests/specs/session-state-lifecycle.e2e.ts +++ b/tests/specs/session-state-lifecycle.e2e.ts @@ -1,10 +1,28 @@ import * as path from 'path'; import * as fs from 'fs'; import JSZip from 'jszip'; -import { MINIMAL_501_SESSION, PROSTATEX_DATASET } from '../datasets'; -import { openUrls, SESSION_SAVE_TIMEOUT, waitForFileExists } from './utils'; -import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +import { MINIMAL_501_SESSION } from '../datasets'; +import { PROSTATE_610_LABELMAP_MANIFEST } from './configTestUtils'; +import { + openVolViewPage, + SESSION_SAVE_TIMEOUT, + waitForFileExists, + writeManifestToFile, +} from './utils'; +import { volViewPage } from '../pageobjects/volview.page'; import { TEMP_DIR } from '../../wdio.shared.conf'; +import { + openAnnotationSegments, + openSegmentShapes, + segmentColor, + segmentNames, + segmentRow, + waitForNamedSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; + +// The 5.0.1 fixture's rectangle carries this name. +const RECTANGLE_SEGMENT_NAME = 'Label 1'; const waitForElementCount = async (selector: string, minCount = 1) => { await browser.waitUntil(async () => { @@ -56,6 +74,13 @@ const loadSession = async () => { await volViewPage.waitForViews(); }; +const openProstateLabelmap = async (fileName: string) => { + await openVolViewPage(fileName); + await openAnnotationSegments(); + await waitForNamedSegments(); + await waitForSegmentContent('Right hip'); +}; + describe('Session state lifecycle', () => { it('migrates 5.0.1 session with rectangle, polygons, and labelmap', async () => { await loadSession(); @@ -63,45 +88,45 @@ describe('Session state lifecycle', () => { const notifications = await volViewPage.getNotificationsCount(); expect(notifications).toEqual(0); - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const measurementsTab = await $('button.v-tab*=Measurements'); - await measurementsTab.waitForClickable(); - await measurementsTab.click(); - - await waitForElementCount('.v-list-item i.mdi-vector-square.tool-icon'); - await waitForElementCount('.v-list-item i.mdi-pentagon-outline.tool-icon'); + await openSegmentShapes(); - const segmentGroupsTab = await $('button.v-tab*=Segment Groups'); - await segmentGroupsTab.waitForClickable(); - await segmentGroupsTab.click(); + await waitForElementCount( + '[data-testid="segment-shape-row"] i.mdi-vector-square' + ); + await waitForElementCount( + '[data-testid="segment-shape-row"] i.mdi-pentagon-outline' + ); - await waitForElementCount('.segment-group-list .v-list-item'); + await openAnnotationSegments(); + await waitForNamedSegments(); }); - it('edited label strokeWidth persists through save/load cycle', async () => { + it('edited type strokeWidth persists through save/load cycle', async () => { await loadSession(); - const editedStrokeWidth = 9; + const editedStrokeWidth = 5; - // Activate rectangle tool to show RectangleControls with LabelControls + // Rectangle draws with the entry selected in the Segments list, which is + // where the session's rectangle segment shows up. await volViewPage.activateRectangle(); - - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' + await openAnnotationSegments(); + await waitForNamedSegments(); + + // The list shows every segment in the registry, so pick the one the + // session's rectangle actually carries rather than the first row. + const row = await segmentRow(RECTANGLE_SEGMENT_NAME); + await row.waitForDisplayed(); + const editButton = await row.$('button[data-testid="edit-segment-button"]'); + await editButton.click(); + + const slider = await volViewPage.segmentStrokeWidthSlider; + await slider.waitForClickable(); + await slider.click(); + await browser.keys('End'); + await expect(slider).toHaveAttribute( + 'aria-valuenow', + editedStrokeWidth.toString() ); - await annotationsTab.click(); - - await waitForElementCount('button[data-testid="edit-label-button"]'); - - const buttons = await volViewPage.editLabelButtons; - await buttons[0].click(); - - const input = await volViewPage.labelStrokeWidthInput; - await setValueVueInput(input, editedStrokeWidth.toString()); const done = await volViewPage.editLabelModalDoneButton; await done.click(); @@ -113,32 +138,80 @@ describe('Session state lifecycle', () => { await volViewPage.waitForViews(); const { manifest: reloadedManifest } = await saveAndParseManifest(); + // Stroke width belongs to the type the rectangle names, not to the shape. const tools = reloadedManifest.tools as { - rectangles: { tools: Array<{ strokeWidth: number }> }; + rectangles: { tools: Array<{ segmentId: string }> }; }; - expect(tools.rectangles.tools[0].strokeWidth).toEqual(editedStrokeWidth); + const segments = reloadedManifest.segments as Array<{ + id: string; + strokeWidth?: number; + }>; + const carried = segments.find( + (segment) => segment.id === tools.rectangles.tools[0].segmentId + ); + expect(carried?.strokeWidth).toEqual(editedStrokeWidth); }); - it('sanitizes segment group names when saving labelmaps into the session zip', async () => { - await openUrls([PROSTATEX_DATASET]); - - const segmentGroupName = 'Liver: left/right*?'; + it('sanitizes stored labelmap names when saving them into the session zip', async () => { + // The panel is one flat list per image with no group left to name, so a + // filesystem-hostile name now reaches the app through the manifest. + const storedName = 'Liver: left/right*?'; const sanitizedFilePath = 'segmentations/Liver left right.vti'; - - await volViewPage.createSegmentGroup(segmentGroupName); + const source = PROSTATE_610_LABELMAP_MANIFEST.labelMaps[0]; + const fileName = `hostile-labelmap-name-${Date.now()}.volview.json`; + await writeManifestToFile( + { + ...PROSTATE_610_LABELMAP_MANIFEST, + labelMaps: [ + { ...source, metadata: { ...source.metadata, name: storedName } }, + ], + }, + fileName + ); + await openProstateLabelmap(fileName); const { manifest, zip } = await saveAndParseManifest(); if (!zip) { throw new Error('Expected saved session zip to be available'); } - const segmentGroups = manifest.segmentGroups as Array<{ - path: string; - metadata: { name: string }; + // A save writes one archive entry per mask, named on the mask's own + // labelmap binding. + const segmentations = manifest.segmentations as Array<{ + masks: Array<{ + representations: { labelmap?: { path: string; name: string } }; + }>; }>; + const bindings = segmentations.flatMap((segmentation) => + segmentation.masks.flatMap((mask) => mask.representations.labelmap ?? []) + ); - expect(segmentGroups.length).toEqual(1); - expect(segmentGroups[0].metadata.name).toEqual(segmentGroupName); - expect(segmentGroups[0].path).toEqual(sanitizedFilePath); + expect(bindings.length).toBeGreaterThan(0); + // The stored name survives; only the path it becomes is sanitized. + expect(bindings.every((binding) => binding.name === storedName)).toBe(true); + expect(bindings[0].path).toEqual(sanitizedFilePath); expect(Object.keys(zip.files)).toContain(sanitizedFilePath); }); + + it('re-saves a migrated legacy labelmap with its segments intact', async () => { + const fileName = `legacy-labelmap-${Date.now()}.volview.json`; + await writeManifestToFile(PROSTATE_610_LABELMAP_MANIFEST, fileName); + await openProstateLabelmap(fileName); + + // The 6.1.0 labelMaps entry names this segment and colors it red. + expect(await segmentNames()).toEqual(['Right hip']); + const segmentColorBefore = await segmentColor('Right hip'); + + const { session, manifest } = await saveAndParseManifest(); + expect(manifest.version).toEqual('7.0.0'); + + await volViewPage.open(`?urls=[tmp/${session}]`); + await volViewPage.waitForViews(); + expect(await volViewPage.getNotificationsCount()).toEqual(0); + + await openAnnotationSegments(); + await waitForNamedSegments(); + expect(await segmentNames()).toEqual(['Right hip']); + await waitForSegmentContent('Right hip'); + expect(await segmentColor('Right hip')).toEqual(segmentColorBefore); + }); }); diff --git a/tests/specs/sparse-manifest-prostate-rectangle.e2e.ts b/tests/specs/sparse-manifest-prostate-rectangle.e2e.ts index 31f400847..0f424a44f 100644 --- a/tests/specs/sparse-manifest-prostate-rectangle.e2e.ts +++ b/tests/specs/sparse-manifest-prostate-rectangle.e2e.ts @@ -1,5 +1,6 @@ import { PROSTATEX_DATASET } from '../datasets'; import { openVolViewPage, writeManifestToZip } from './utils'; +import { openSegmentShapes } from './segmentationTestUtils'; describe('Sparse manifest with prostate rectangle', () => { it('loads prostate dataset with lesion rectangle annotation', async () => { @@ -52,19 +53,12 @@ describe('Sparse manifest with prostate rectangle', () => { await writeManifestToZip(sparseManifest, fileName); await openVolViewPage(fileName); - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const measurementsTab = await $('button.v-tab*=Measurements'); - await measurementsTab.waitForClickable(); - await measurementsTab.click(); + await openSegmentShapes(); await browser.waitUntil( async () => { const rectangleEntries = await $$( - '.v-list-item i.mdi-vector-square.tool-icon' + '[data-testid="segment-shape-row"] i.mdi-vector-square' ); const count = await rectangleEntries.length; return count >= 1; diff --git a/tests/specs/sparse-manifest.e2e.ts b/tests/specs/sparse-manifest.e2e.ts index fcbb27b37..a13ee579d 100644 --- a/tests/specs/sparse-manifest.e2e.ts +++ b/tests/specs/sparse-manifest.e2e.ts @@ -5,7 +5,12 @@ import { writeManifestToFile, writeManifestToZip, } from './utils'; -import { DOWNLOAD_TIMEOUT } from '../../wdio.shared.conf'; +import { + openAnnotationSegments, + openSegmentShapes, + waitForNamedSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; describe('Sparse manifest.json', () => { it('loads manifest with only URL data source', async () => { @@ -64,19 +69,12 @@ describe('Sparse manifest.json', () => { await writeManifestToZip(sparseManifest, fileName); await openVolViewPage(fileName); - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const measurementsTab = await $('button.v-tab*=Measurements'); - await measurementsTab.waitForClickable(); - await measurementsTab.click(); + await openSegmentShapes(); await browser.waitUntil( async () => { const rectangleEntries = await $$( - '.v-list-item i.mdi-vector-square.tool-icon' + '[data-testid="segment-shape-row"] i.mdi-vector-square' ); const count = await rectangleEntries.length; return count >= 1; @@ -110,26 +108,9 @@ describe('Sparse manifest.json', () => { await writeManifestToFile(PROSTATE_610_LABELMAP_MANIFEST, fileName); await openVolViewPage(fileName); - const annotationsTab = await $( - 'button[data-testid="module-tab-Annotations"]' - ); - await annotationsTab.click(); - - const segmentGroupsTab = await $('button.v-tab*=Segment Groups'); - await segmentGroupsTab.waitForClickable(); - await segmentGroupsTab.click(); - - await browser.waitUntil( - async () => { - const segmentGroups = await $$('.segment-group-list .v-list-item'); - const count = await segmentGroups.length; - return count >= 1; - }, - { - timeout: DOWNLOAD_TIMEOUT, - timeoutMsg: 'Segment group not found in segment groups list', - } - ); + await openAnnotationSegments(); + await waitForNamedSegments(); + await waitForSegmentContent('Right hip'); // Verify the segment group source image is NOT in the Anonymous section const dataTab = await $('button[data-testid="module-tab-Data"]'); diff --git a/tests/specs/ultrasound-spacing.e2e.ts b/tests/specs/ultrasound-spacing.e2e.ts index a50b214c7..3fc6f8984 100644 --- a/tests/specs/ultrasound-spacing.e2e.ts +++ b/tests/specs/ultrasound-spacing.e2e.ts @@ -1,6 +1,7 @@ import { US_MULTIFRAME_DICOM } from '../datasets'; import { openUrls } from './utils'; import { volViewPage } from '../pageobjects/volview.page'; +import { openSegmentShapes } from './segmentationTestUtils'; // The exact ruler length depends on platform-specific viewport geometry, but // the unspaced fallback is roughly twice as large because the DICOM fixture's @@ -25,17 +26,12 @@ describe('Ultrasound image spacing', () => { await canvas.click({ x: 0, y: -CLICK_DY / 2 }); await canvas.click({ x: 0, y: CLICK_DY / 2 }); - const annotationsTab = await volViewPage.annotationsModuleTab; - await annotationsTab.click(); - - const measurementsTab = await $('button.v-tab*=Measurements'); - await measurementsTab.waitForClickable(); - await measurementsTab.click(); + await openSegmentShapes(); let lengthMm = 0; await browser.waitUntil( async () => { - const spans = await $$('.v-list-item .value'); + const spans = await $$('[data-testid="segment-shape-row"]'); for (const span of spans) { const text = await span.getText(); const match = text.match(/([\d.]+)\s*mm/); diff --git a/tests/specs/utils.ts b/tests/specs/utils.ts index 4125b845e..f74cd6b8e 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -116,6 +116,27 @@ export const waitForFileExists = (filePath: string, timeout: number) => }); }); +/** + * Waits for a download to land and to finish being written. The file appears + * empty first, so its existence alone is not enough to read it. + */ +export const waitForDownload = async (filePath: string, timeout: number) => { + await waitForFileExists(filePath, timeout); + await browser.waitUntil( + () => { + try { + return fs.statSync(filePath).size > 0; + } catch { + return false; + } + }, + { + interval: 500, + timeoutMsg: `${path.basename(filePath)} stayed 0 bytes`, + } + ); +}; + export async function openUrls(datasets: ReadonlyArray) { const manifest = { resources: datasets.map(({ name }) => ({ url: `/tmp/${name}` })),