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..8b6510f4e 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 color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored on labels.", "type": "string" } }, @@ -46,12 +48,14 @@ "type": "object", "properties": { "color": { + "description": "A hex color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored on labels.", "type": "string" } }, @@ -68,12 +72,14 @@ "type": "object", "properties": { "color": { + "description": "A hex color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored on labels.", "type": "string" } }, diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index c9b2ee04e..71adfe668 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 may take more than one staged file, listed in `uris`; when absent or false, it takes one. The client honors this for labelmap inputs, staging 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 color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored on labels.", "type": "string" } }, @@ -1723,12 +1725,14 @@ "type": "object", "properties": { "color": { + "description": "A hex color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored on labels.", "type": "string" } }, @@ -1745,12 +1749,14 @@ "type": "object", "properties": { "color": { + "description": "A hex color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color.", "type": "string" }, "strokeWidth": { "type": "number" }, "fillColor": { + "description": "Accepted so an existing producer keeps validating, and ignored on labels.", "type": "string" } }, diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index 9e08b404f..522628bf5 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 may take more than one staged file, listed in `uris`; when absent or false, it takes one. The client honors this for labelmap inputs, staging 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..07588353c 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 color such as `#d60000` or a CSS color keyword such as `lime`. It styles only a segment the label creates: a label whose name the client already holds keeps that segment's color and stroke width. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored and reported to the user, and a new segment then takes an automatic color." + ), strokeWidth: z.number().optional(), - fillColor: z.string().optional(), + fillColor: z + .string() + .optional() + .describe( + 'Accepted so an existing producer keeps validating, and ignored on labels.' + ), }); export type AnnotationLabel = z.infer; diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts index 0097d72bc..c9a8256e1 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 may take more than one staged file, listed in `uris`; when absent or false, it takes one. The client honors this for labelmap inputs, staging 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..177890c4d 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,129 @@ 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. `defaultLabels` stood in for the tools that declared no record of their own, so it +is read only when at least one of the three tool records is omitted. A section whose +records are all omitted or `null` leaves the registry alone. A rectangle label's +`fillColor` is dropped with a warning, 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, `labels` is ignored, and the warning says so. + +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 +309,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 +325,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 +355,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..f8fd42d4b 100644 --- a/docs/server.md +++ b/docs/server.md @@ -11,9 +11,9 @@ 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 -works, since VolView knows only the shared vocabulary and never a backend's -native task format. +images, layers, segmentations, or annotations. Any service that implements the +contract works, since VolView knows only the shared vocabulary and never a +backend's native task format. [girder_volview](https://github.com/DigitalSlideArchive/girder_volview) is a working implementation that runs tasks as 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..5067a16dd 100644 --- a/docs/toolbar.md +++ b/docs/toolbar.md @@ -14,19 +14,52 @@ 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, use `q` and `w` to cycle through segments, or press `1` +through `0` to select one of the first ten (each row shows its key). Use "New segment" +to add one, and its color dot or edit button to change its name and appearance. Drag a +row, or press Alt+Up or Alt+Down, to reorder segments and their keys. The selection +applies across all four tools and images. + +Each segment row has Reveal, Edit, Lock, Hide and Delete buttons. Reveal jumps to the +segment's mask or shapes on the current image and stays disabled when it has none +there. A locked segment keeps its voxels when another segment paints over it, and +cannot be edited or deleted until it is unlocked. Delete removes the segment with its +masks and shapes from every image. The buttons beside the "Segments" heading lock or +hide every segment at once and save the current image's segments. Saving writes one +file, or several bundled into a zip when segments overlap or number more than 65535. + +The "Display" section sets Fill Opacity, Outline Opacity and Outline Thickness for the +segments on the current image, scaling each segment's own opacities. + +The "Measurements" section lists the rectangles, polygons and rulers on the current +image. Each row shows its segment's color (click it to move the shape to another +segment), its placement and, for a ruler, its length, with Reveal, Hide and Delete +buttons. Select rows to hide or delete several at once. ### 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. The +Eyedropper selects the segment under the brush when you click; hold `d` to use it +while painting. "Sync Views" moves the other 2D views to the slice under the brush. +The "Process" panel runs Fill Holes, Fill Between and Smooth, which never take voxels +another segment holds. + +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 When the rectangle tool is selected, the left mouse button is used to place and adjust rectangle control points. Right click a rectangle control point to delete the rectangle. -The "Annotations" tab lists all rectangles and provides jump-to and delete controls. +The Measurements section in "Annotations" lists the rectangles on the current image, with Reveal, Hide 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,67 +77,32 @@ 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. +Find it under Measurements in "Annotations" to see its length, jump to its slice or +cine frame, or delete it. -Ruler annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New rulers use the selected segment from "Annotations". ![2D Annotations](./assets/11-volview-paint-notes.jpg) -### Label Configuration - -If VolView loads a JSON file matching the schemas below, labels are added to the 2D annotation tools. -Example configuration JSON: - -```json -{ - "labels": { - "rulerLabels": { - "big": { "color": "#ff0000" }, - "small": { "color": "white" } - }, - "rectangleLabels": { - "innocuous": { "color": "white", "fillColor": "#00ff0030" }, - "lesion": { "color": "#ff0000", "fillColor": "transparent" }, - "tumor": { "color": "green", "fillColor": "transparent" } - } - } -} -``` - -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" - } - } - } -} -``` +### Segment configuration -Tools will fallback to `defaultLabels` section if the tool has no specific labels property, -ie `rectangleLabels` or `rulerLabels`. +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": { - "defaultLabels": { - "artifact": { "color": "gray" }, - "needs-review": { "color": "#FFBF00" } - } + "segments": { + "innocuous": { "color": "white" }, + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3 } } } ``` diff --git a/src/__tests__/segmentGroupRemoval.spec.ts b/src/__tests__/segmentGroupRemoval.spec.ts new file mode 100644 index 000000000..55e44a555 --- /dev/null +++ b/src/__tests__/segmentGroupRemoval.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { exists, hits, isTest, sourceFiles } from './sourceAudit'; + +// Source-level checks keep deleted group infrastructure from returning. + +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( + [] + ); + }); +}); 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..5fa988c9e 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: +// Converts other DataSources into segments on the primary image: // - 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..34e2e0b22 100644 --- a/src/components/AnnotationsModule.vue +++ b/src/components/AnnotationsModule.vue @@ -1,85 +1,33 @@ - - - + diff --git a/src/components/CineViewer.vue b/src/components/CineViewer.vue index 85e7ee2fa..190f21a21 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 = { @@ -203,7 +204,7 @@ const selectionPoints = computed(() => { ({ tool }) => tool.imageID === currentImageID.value && tool.frame === currentFrame.value && - !tool.hidden + isToolVisible(tool) ) .flatMap(({ store, tool }) => store.getPoints(tool.id)); }); diff --git a/src/components/ColorDot.vue b/src/components/ColorDot.vue index 267a48cb6..67b6370c2 100644 --- a/src/components/ColorDot.vue +++ b/src/components/ColorDot.vue @@ -1,24 +1,17 @@ diff --git a/src/components/ControlsStripTools.vue b/src/components/ControlsStripTools.vue index e6c02044f..28be15f42 100644 --- a/src/components/ControlsStripTools.vue +++ b/src/components/ControlsStripTools.vue @@ -68,9 +68,7 @@ icon="mdi-brush" :name="`Paint [${nameToShortcut['Paint']}]`" :buttonClass="['tool-btn', active ? 'tool-btn-selected' : '']" - :disabled=" - noCurrentImage || isObliqueLayout || isDisallowedOnCine(Tools.Paint) - " + :disabled="!!paintUnavailableReason" @click="toggle" > @@ -78,40 +76,31 @@ v-slot:default="{ active, toggle }" :value="Tools.Rectangle" > - - - + /> - - - + /> - - - + />
@@ -146,9 +135,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 +152,6 @@ export default defineComponent({ GroupableItem, CropControls, ResetViews, - RulerControls, - RectangleControls, - PolygonControls, WindowLevelControls, }, setup() { @@ -192,6 +175,9 @@ export default defineComponent({ const isCineActive = computed(() => activeEffective.value?.kind === 'cine'); const isDisallowedOnCine = (tool: Tools) => isCineActive.value && !isToolAllowedFor(tool, activeEffective.value); + const paintUnavailableReason = computed( + () => toolStore.paintUnavailableReason + ); const paintMenu = ref(false); const cropMenu = ref(false); @@ -232,6 +218,7 @@ export default defineComponent({ noCurrentImage, isObliqueLayout, isDisallowedOnCine, + paintUnavailableReason, Tools, paintMenu, cropMenu, 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..c520e58fc --- /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..dc9ded5c1 100644 --- a/src/components/ImageDataBrowser.vue +++ b/src/components/ImageDataBrowser.vue @@ -3,8 +3,10 @@ import { computed, defineComponent, reactive, watch } from 'vue'; import ItemGroup from '@/src/components/ItemGroup.vue'; import GroupableItem from '@/src/components/GroupableItem.vue'; import ImageListCard from '@/src/components/ImageListCard.vue'; +import ReasonedAction from '@/src/components/ReasonedAction.vue'; +import SegmentationConversionProgress from '@/src/components/SegmentationConversionProgress.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, @@ -32,12 +34,14 @@ export default defineComponent({ ItemGroup, GroupableItem, ImageListCard, + ReasonedAction, + SegmentationConversionProgress, }, setup() { const imageStore = useImageStore(); const dataStore = useDatasetStore(); const layersStore = useLayersStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); const viewSliceStore = useViewSliceStore(); const viewCameraStore = useViewCameraStore(); const imageCacheStore = useImageCacheStore(); @@ -68,6 +72,8 @@ export default defineComponent({ id !== selectedImageID && currentImageID.value != null; const metadata = imageCacheStore.getImageMetadata(id) ?? defaultImageMetadata(); + const convertingToSegmentation = + segmentationStore.convertingLabelmaps.has(id); return { id, cacheKey: imageCacheKey(id), @@ -78,6 +84,12 @@ export default defineComponent({ spacing: [...metadata.spacing].map((s) => s.toFixed(2)), layerable, layerLoading, + convertingToSegmentation, + convertReason: convertingToSegmentation + ? 'Already adding this image as a segmentation' + : layerable + ? '' + : 'Must load a background image before converting', isLayer, layerHandler: () => { if (!layerLoading && layerable) { @@ -155,7 +167,7 @@ export default defineComponent({ function convertToLabelMap(key: string) { if (currentImageID.value) { - segmentGroupStore.convertImageToLabelmap(key, currentImageID.value); + segmentationStore.startLabelmapConversion(key, currentImageID.value); } } @@ -274,6 +286,9 @@ export default defineComponent({ @click="select" @dragstart="onDragStart(image.id, $event)" > +
Add as layer - - - mdi-alert - - Add as Segment Group - - Must load a background image before converting - - + + mdi-alert + + Add as segmentation + + Show in all views diff --git a/src/components/LabelControls.vue b/src/components/LabelControls.vue deleted file mode 100644 index 34e6b7c54..000000000 --- a/src/components/LabelControls.vue +++ /dev/null @@ -1,158 +0,0 @@ - - - - - diff --git a/src/components/LabelEditor.vue b/src/components/LabelEditor.vue deleted file mode 100644 index b2535f29d..000000000 --- a/src/components/LabelEditor.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - 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..cd1ef21a4 100644 --- a/src/components/MeasurementsToolList.vue +++ b/src/components/MeasurementsToolList.vue @@ -1,240 +1,248 @@ - - diff --git a/src/components/MenuControlButton.vue b/src/components/MenuControlButton.vue index 3b0f26625..75ed02b5c 100644 --- a/src/components/MenuControlButton.vue +++ b/src/components/MenuControlButton.vue @@ -11,7 +11,6 @@ export default defineComponent({ size: { type: [Number, String], default: 40 }, active: Boolean, disabled: Boolean, - mobileOnlyMenu: Boolean, }, components: { ControlButton, @@ -23,15 +22,6 @@ export default defineComponent({ const menuOn = ref(false); - const enableMenu = computed( - () => !props.mobileOnlyMenu || display.mobile.value - ); - - // Disable menu if mobile only - watch(menuOn, (on) => { - menuOn.value = on && enableMenu.value; - }); - // Turn off menu if tool deselected const active = computed(() => props.active); watch(active, (on) => { @@ -40,7 +30,7 @@ export default defineComponent({ } }); - return { showLeft, menuOn, enableMenu }; + return { showLeft, menuOn }; }, }); @@ -64,7 +54,7 @@ export default defineComponent({ v-bind="props" > diff --git a/src/components/ModulePanel.vue b/src/components/ModulePanel.vue index d310bea04..c55b45b58 100644 --- a/src/components/ModulePanel.vue +++ b/src/components/ModulePanel.vue @@ -184,6 +184,7 @@ export default defineComponent({ position: relative; flex: 2; min-height: 0; + container-type: size; overflow-x: hidden; overflow-y: auto; } diff --git a/src/components/PaintControls.vue b/src/components/PaintControls.vue deleted file mode 100644 index 932132f46..000000000 --- a/src/components/PaintControls.vue +++ /dev/null @@ -1,247 +0,0 @@ - - - - - diff --git a/src/components/PatientStudyVolumeBrowser.vue b/src/components/PatientStudyVolumeBrowser.vue index a415f55f1..8f8453489 100644 --- a/src/components/PatientStudyVolumeBrowser.vue +++ b/src/components/PatientStudyVolumeBrowser.vue @@ -10,9 +10,11 @@ import { useMultiSelection } from '@/src/composables/useMultiSelection'; import { useMessageStore } from '@/src/store/messages'; import { useLayersStore } from '@/src/store/datasets-layers'; import PersistentOverlay from '@/src/components//PersistentOverlay.vue'; +import SegmentationConversionProgress from '@/src/components/SegmentationConversionProgress.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}`; @@ -33,6 +35,7 @@ export default defineComponent({ components: { GroupableItem, PersistentOverlay, + SegmentationConversionProgress, }, setup(props) { const { volumeKeys } = toRefs(props); @@ -41,6 +44,7 @@ export default defineComponent({ const layersStore = useLayersStore(); const imageCacheStore = useImageCacheStore(); const viewStore = useViewStore(); + const segmentationStore = useSegmentationStore(); const { currentImageID } = useCurrentImage(); const volumes = computed(() => { @@ -73,6 +77,8 @@ export default defineComponent({ isLayer, layerable, layerLoading, + convertingToSegmentation: + segmentationStore.convertingLabelmaps.has(volumeKey), layerHandler: () => { if (!layerLoading && layerable) { if (isLayer) @@ -250,7 +256,10 @@ export default defineComponent({ justify="center" > @@ -264,7 +273,9 @@ export default defineComponent({ - +
- + +
[{{ volume.info.NumberOfSlices }}] diff --git a/src/components/PolygonControls.vue b/src/components/PolygonControls.vue deleted file mode 100644 index e92d27321..000000000 --- a/src/components/PolygonControls.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/components/ProcessControls.vue b/src/components/ProcessControls.vue index dea798e2b..bc7a064d5 100644 --- a/src/components/ProcessControls.vue +++ b/src/components/ProcessControls.vue @@ -7,6 +7,7 @@ import { computed } from 'vue'; -import { usePaintProcessStore } from '@/src/store/tools/paintProcess'; +import { usePaintProcessStore } from '@/src/segmentation/editing/paintProcess'; import ProcessTypeSelector from './ProcessTypeSelector.vue'; -import ProcessWorkflow from './ProcessWorkflow.vue'; +import ProcessWorkflow from '@/src/segmentation/components/ProcessWorkflow.vue'; import { PROCESS_DEFINITIONS } from './processes'; const processStore = usePaintProcessStore(); diff --git a/src/components/ProcessTypeSelector.vue b/src/components/ProcessTypeSelector.vue index 78da1a09e..89ed57698 100644 --- a/src/components/ProcessTypeSelector.vue +++ b/src/components/ProcessTypeSelector.vue @@ -39,7 +39,7 @@ + + diff --git a/src/components/RectangleControls.vue b/src/components/RectangleControls.vue deleted file mode 100644 index 96263f71c..000000000 --- a/src/components/RectangleControls.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - 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/SegmentationConversionProgress.vue b/src/components/SegmentationConversionProgress.vue new file mode 100644 index 000000000..a6b501ab3 --- /dev/null +++ b/src/components/SegmentationConversionProgress.vue @@ -0,0 +1,9 @@ + diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index 09707c9d5..83a98ae79 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -105,12 +105,11 @@ :axis="viewAxis" > + + diff --git a/src/components/ProcessWorkflow.vue b/src/segmentation/components/ProcessWorkflow.vue similarity index 52% rename from src/components/ProcessWorkflow.vue rename to src/segmentation/components/ProcessWorkflow.vue index 743437caa..2377e4f63 100644 --- a/src/components/ProcessWorkflow.vue +++ b/src/segmentation/components/ProcessWorkflow.vue @@ -9,16 +9,21 @@ { 'mb-4': processStep === 'previewing' }, ]" > - - Preview - + + Preview + + - + + mdi-eye-outline Original - + mdi-eye-settings Processed @@ -58,15 +66,19 @@ diff --git a/src/segmentation/components/SegmentAssignmentList.vue b/src/segmentation/components/SegmentAssignmentList.vue new file mode 100644 index 000000000..ebee711c9 --- /dev/null +++ b/src/segmentation/components/SegmentAssignmentList.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/segmentation/components/SegmentEditor.vue b/src/segmentation/components/SegmentEditor.vue new file mode 100644 index 000000000..acf84a523 --- /dev/null +++ b/src/segmentation/components/SegmentEditor.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/src/segmentation/components/SegmentList.vue b/src/segmentation/components/SegmentList.vue new file mode 100644 index 000000000..9c4f78aa8 --- /dev/null +++ b/src/segmentation/components/SegmentList.vue @@ -0,0 +1,532 @@ + + + + + diff --git a/src/segmentation/components/SegmentListActions.vue b/src/segmentation/components/SegmentListActions.vue new file mode 100644 index 000000000..2056e234f --- /dev/null +++ b/src/segmentation/components/SegmentListActions.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts new file mode 100644 index 000000000..c6c20941e --- /dev/null +++ b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts @@ -0,0 +1,183 @@ +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 type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import { useViewStore } from '@/src/store/views'; +import { useToolStore } from '@/src/store/tools'; +import { Tools } from '@/src/store/tools/types'; +import { markCine } from '@/src/core/cine/__tests__/cineFixtures'; + +// --------------------------------------------------------------------------- +// 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: '' }, + VTooltip: { + props: ['disabled'], + 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'); + +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(); + useToolStore().setCurrentTool(Tools.Paint); +}); + +describe('the process preview toggle', () => { + 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(wrapper.find('.btn-toggle').exists()).toBe(true); + return { wrapper, values }; + }; + + it('leaves the preview alone when the showing button is clicked again', async () => { + const { wrapper, values } = await previewing(); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + + await button(wrapper, 'Processed').trigger('click'); + + 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 } = await previewing(); + + await button(wrapper, 'Original').trigger('click'); + + expect(selected(wrapper)).toBe('0'); + expect(values()).toEqual([1, 0]); + + await button(wrapper, 'Original').trigger('click'); + + expect(selected(wrapper)).toBe('0'); + expect(values()).toEqual([1, 0]); + }); + + it('still moves between the two', async () => { + const { wrapper, values } = await previewing(); + + await button(wrapper, 'Original').trigger('click'); + await button(wrapper, 'Processed').trigger('click'); + + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + }); +}); + +describe('the process Preview button', () => { + const mountWorkflow = () => + mount(ProcessWorkflow, { + props: { algorithm: processed }, + global: globalOptions, + }); + + it('stays visible but disabled, saying why, when nothing can run', () => { + const wrapper = mountWorkflow(); + + expect(button(wrapper, 'Preview').attributes('disabled')).toBeDefined(); + expect(wrapper.get('[role="tooltip"]').text()).toBe( + 'No segment content to process' + ); + }); + + it('asks for the Paint tool while another tool is active', async () => { + addActiveSegment(new Uint8Array([1, 0])); + useToolStore().setCurrentTool(Tools.WindowLevel); + const wrapper = mountWorkflow(); + await nextTick(); + + expect(button(wrapper, 'Preview').attributes('disabled')).toBeDefined(); + expect(wrapper.get('[role="tooltip"]').text()).toBe( + 'Select the Paint tool to run a process' + ); + }); + + it.each([ + ['no image is viewed', undefined, 'Load an image to paint'], + ['a clip is viewed', 'cine-1', 'A clip cannot be painted'], + ])( + 'says why Paint is unavailable when %s', + async (_case, imageId, reason) => { + addActiveSegment(new Uint8Array([1, 0])); + markCine('cine-1'); + useViewStore().setDataForAllViews(imageId); + const wrapper = mountWorkflow(); + await nextTick(); + + expect(button(wrapper, 'Preview').attributes('disabled')).toBeDefined(); + expect(wrapper.get('[role="tooltip"]').text()).toBe(reason); + } + ); + + it('is enabled with no reason once a segment has content', async () => { + addActiveSegment(new Uint8Array([1, 0])); + const wrapper = mountWorkflow(); + await nextTick(); + + expect(button(wrapper, 'Preview').attributes('disabled')).toBeUndefined(); + expect(wrapper.find('[role="tooltip"]').exists()).toBe(false); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts b/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts new file mode 100644 index 000000000..bb45353bd --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { enableAutoUnmount, mount } from '@vue/test-utils'; + +import SegmentAssignmentList from '@/src/segmentation/components/SegmentAssignmentList.vue'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { NO_NAME } from '@/src/constants'; + +enableAutoUnmount(afterEach); + +const globalOptions = { + stubs: { + VList: { template: '
' }, + VListItem: { + template: '
  • ', + }, + VListItemTitle: { template: '' }, + }, +}; + +describe('segment assignment list', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('titles an unnamed segment the way the segment list does', () => { + const registry = useSegmentStore().segments; + registry.mintSegment({ name: 'Tumor' }); + registry.mintSegment({ name: '' }); + + const wrapper = mount(SegmentAssignmentList, { global: globalOptions }); + + expect(wrapper.findAll('.title').map((title) => title.text())).toEqual([ + 'Tumor', + NO_NAME, + ]); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentEditor.spec.ts b/src/segmentation/components/__tests__/SegmentEditor.spec.ts new file mode 100644 index 000000000..e68ea71d8 --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentEditor.spec.ts @@ -0,0 +1,151 @@ +import { defineComponent } from 'vue'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; + +import SegmentEditor from '@/src/segmentation/components/SegmentEditor.vue'; + +const Shell = { template: '
    ' }; + +const ButtonStub = defineComponent({ + name: 'VBtn', + props: ['disabled'], + template: '', +}); + +const TextFieldStub = defineComponent({ + name: 'VTextField', + props: ['modelValue', 'rules'], + template: '', +}); + +const SliderStub = defineComponent({ + name: 'VSlider', + props: ['name', '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, + locked: false, + }, + global: { + stubs: { + VCard: Shell, + VCardItem: Shell, + VCardActions: Shell, + VBtn: ButtonStub, + VTooltip: Shell, + VSpacer: true, + VColorPicker: true, + VTextField: TextFieldStub, + VSlider: SliderStub, + }, + }, + }); + +const actionButton = (wrapper: VueWrapper, label: string) => + wrapper + .findAllComponents(ButtonStub) + .find((candidate) => candidate.text() === label)!; + +describe('segment editor name validation', () => { + it('allows an unchanged duplicate name', () => { + const wrapper = mountEditor(); + + expect( + actionButton(wrapper, 'Done').attributes('disabled') + ).toBeUndefined(); + const [rule] = wrapper.findComponent(TextFieldStub).props('rules'); + expect(rule('Tumor')).toBe(true); + }); + + it('rejects changing to another segment’s name', async () => { + const wrapper = mountEditor(); + + await wrapper.setProps({ name: ' Node ' }); + + const done = actionButton(wrapper, 'Done'); + expect(done.attributes('disabled')).toBeDefined(); + expect(done.element.parentElement?.textContent).toContain( + 'Choose a unique name' + ); + const [rule] = wrapper.findComponent(TextFieldStub).props('rules'); + expect(rule(' Node ')).toBe('Name is not unique'); + }); +}); + +describe('editor delete', () => { + it('says it removes the segment from every image', () => { + const remove = actionButton(mountEditor(), 'Delete'); + + expect(remove.attributes('aria-label')).toBe( + 'Delete this segment from every image' + ); + expect(remove.element.parentElement?.textContent).toContain( + 'Delete from every image' + ); + }); +}); + +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 = actionButton(wrapper, action); + await wrapper.setProps({ locked: true }); + 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({ locked: false }); + expect(button.attributes('disabled')).toBeUndefined(); + await button.trigger('click'); + const [emitted, other] = + action === 'Delete' ? ['delete', 'done'] : ['done', 'delete']; + expect(wrapper.emitted(emitted)).toHaveLength(1); + expect(wrapper.emitted(other)).toBeUndefined(); + } + ); +}); + +describe('segment editor stroke width', () => { + it('offers integer stroke widths from 1 through 5', () => { + const wrapper = mountEditor(); + const strokeWidth = wrapper + .findAllComponents(SliderStub) + .find((slider) => slider.props('name') === '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('name') === '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..a038169fe --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentList.spec.ts @@ -0,0 +1,1455 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + type Index3, + maskOn, + lockSegment, + seedVoxel, + seatImage, + seatSpecImage, + store, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { defineComponent, nextTick, ref } from 'vue'; +import { enableAutoUnmount, mount, VueWrapper } from '@vue/test-utils'; + +import SegmentList from '@/src/segmentation/components/SegmentList.vue'; +import { useMessageStore } from '@/src/store/messages'; +import useLoadDataStore from '@/src/store/load-data'; +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 segments: rows are the shared registry's segments, keyed +// on segment id, offered whether or not this image has a mask for them. The +// visibility and lock controls belong to the shared segment, and the +// display sliders to the viewed image's segmentation. +// --------------------------------------------------------------------------- + +const segments = () => useSegmentStore().segments; + +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 name their thumbs announce. +const SliderStub = defineComponent({ + name: 'VSlider', + props: ['name', 'modelValue', 'min', 'max', 'step', 'disabled'], + 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 mountListWithTooltips = () => + mount(SegmentList, { + global: { + stubs: { + ...globalOptions.stubs, + VTooltip: { template: '' }, + }, + }, + }); + +const itemList = (wrapper: VueWrapper) => wrapper.findComponent(ItemListStub); + +const selectedRow = (wrapper: VueWrapper) => + itemList(wrapper).props('modelValue'); + +// The fields the list hands the item list to draw a row with. +const rowShown = (wrapper: VueWrapper, id: string) => { + const items = itemList(wrapper).props('items') as Array<{ + id: string; + name: string; + color: string; + }>; + const row = items.find((item) => item.id === id); + if (!row) throw new Error(`No row for segment ${id}`); + return row; +}; + +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 eyeOf = (wrapper: VueWrapper, id: string) => + rowButton(wrapper, id, ['mdi-eye', 'mdi-eye-off']).text(); + +const lockOf = (wrapper: VueWrapper, id: string) => + rowButton(wrapper, id, ['mdi-lock', 'mdi-lock-open']).text(); + +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 seatSpecImage('img-1'); + await seatSpecImage('img-2'); + await viewImage('img-1'); + }); + + it('lists the registry in creation order, keyed by segment 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 segment that has no voxels yet', async () => { + const unbound = makeMask('img-1', 'Tumor'); + expect(unbound.record.representations.labelmap).toBeUndefined(); + + const wrapper = mountList(); + await nextTick(); + + expect(rowIds(wrapper)).toEqual([unbound.id]); + }); + + it('offers a segment 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]); + }); + + 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]); + }); +}); + +describe('flat segment list with no viewed image', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatSpecImage('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 seatSpecImage('img-1'); + await seatSpecImage('img-2'); + await viewImage('img-1'); + }); + + it('marks the selected segment 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('marks the first row selected while no segment has been chosen', async () => { + const first = makeMask('img-1', 'Tumor'); + makeMask('img-1', 'Node'); + + const wrapper = mountList(); + await nextTick(); + + expect(itemList(wrapper).props('modelValue')).toBe(first.segmentId); + }); + + it('keeps the selected row when the list picks nothing', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + segments().selectSegment(second.segmentId); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('update:model-value', null); + await nextTick(); + + expect(itemList(wrapper).props('modelValue')).toBe(second.segmentId); + }); + + it('selects a segment 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(selectedRow(wrapper)).toBe(second.segmentId); + }); + + it('keeps the selected row on an image the segment 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); + }); +}); + +describe('flat segment list row creation', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatSpecImage('img-1'); + await viewImage('img-1'); + }); + + it('adds a row', async () => { + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(rowIds(wrapper)).toHaveLength(1); + }); + + it('selects the row it adds', async () => { + segments().selectSegment(makeSegment('Tumor')); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(selectedRow(wrapper)).toBe(rowIds(wrapper)[1]); + }); + + it('adds the row for every image at once', async () => { + await seatSpecImage('img-2'); + makeMask('img-2', 'Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + await viewImage('img-2'); + + expect(rowIds(wrapper)).toHaveLength(2); + }); +}); + +describe('flat segment list row actions', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatSpecImage('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(eyeOf(wrapper, second.id)).toBe('mdi-eye-off'); + expect(eyeOf(wrapper, first.id)).toBe('mdi-eye'); + }); + + 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(lockOf(wrapper, second.id)).toBe('mdi-lock'); + expect(lockOf(wrapper, first.id)).toBe('mdi-lock-open'); + }); + + // 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 segment 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(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 segment, 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(eyeOf(wrapper, withoutMask)).toBe('mdi-eye-off'); + }); + + it('offers the header toggles disabled, saying why, while no segment exists', async () => { + const wrapper = mountListWithTooltips(); + await nextTick(); + const lock = () => + wrapper.get('[data-testid="toggle-segments-locked-button"]'); + const eye = () => + wrapper.get('[data-testid="toggle-segments-visible-button"]'); + + [lock(), eye()].forEach((button) => { + expect(button.attributes('disabled')).toBeDefined(); + expect(button.element.parentElement?.textContent).toMatch( + /no segments yet/i + ); + }); + expect(lock().text()).toBe('mdi-lock-open'); + expect(eye().text()).toBe('mdi-eye'); + + makeSegment('Tumor'); + await nextTick(); + + [lock(), eye()].forEach((button) => { + expect(button.attributes('disabled')).toBeUndefined(); + expect(button.element.parentElement?.textContent).toMatch( + /every segment/i + ); + }); + }); + + it('hides every segment at once, on every image', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + await seatSpecImage('img-2'); + const elsewhere = makeMask('img-2', 'Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + await wrapper + .find('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + + [first.id, second.id, elsewhere.id].forEach((id) => + expect(eyeOf(wrapper, id)).toBe('mdi-eye-off') + ); + await viewImage('img-2'); + expect(eyeOf(wrapper, elsewhere.id)).toBe('mdi-eye-off'); + }); +}); + +describe('flat segment list row editing', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatSpecImage('img-1'); + await viewImage('img-1'); + }); + + const reopenEditor = async (wrapper: VueWrapper, id: string) => { + await rowButton(wrapper, id, ['mdi-pencil']).trigger('click'); + await nextTick(); + }; + + const openEditor = async (id: string) => { + const wrapper = mountList(); + await nextTick(); + await reopenEditor(wrapper, id); + return wrapper; + }; + + it('renames the row’s segment 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(rowShown(wrapper, second.id).name).toBe('Lesion'); + }); + + it('recolors the row’s segment 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(rowShown(wrapper, segment.id).color).toBe('#0000ff'); + }); + + it('edits the segment’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(); + await reopenEditor(wrapper, segment.id); + + expect(editor(wrapper).props()).toMatchObject({ + fillOpacity: 0.5, + outlineOpacity: 0.25, + strokeWidth: 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(); + await reopenEditor(wrapper, segment.id); + + expect(rowShown(wrapper, segment.id).name).toBe('Tumor'); + expect(editor(wrapper).props()).toMatchObject({ + name: 'Tumor', + fillOpacity: 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', async () => { + const wrapper = mountList(); + for (const [name, color] of [ + ['Long axis', '#ff0000'], + ['Short axis', '#0000ff'], + ]) { + await wrapper.get('.create-row').trigger('click'); + await rowButton(wrapper, selectedRow(wrapper), ['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(); + } + + expect(rowIds(wrapper).map((id) => rowShown(wrapper, id!))).toMatchObject([ + { name: 'Long axis', color: '#ff0000' }, + { name: 'Short axis', color: '#0000ff' }, + ]); + 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('offers the display controls disabled, saying why, on a clip', async () => { + const wrapper = mountListWithTooltips(); + await nextTick(); + + const sliders = wrapper.findAll('.slider'); + expect(sliders).toHaveLength(3); + sliders.forEach((control) => + expect(control.attributes('disabled')).toBeDefined() + ); + expect(wrapper.get('.display-controls').text()).toMatch( + /a clip has no segmentation to display/i + ); + }); + + it('keeps an empty segment’s reveal disabled', async () => { + const segmentId = segments().addSegment(); + const wrapper = mountList(); + expect( + revealButton(wrapper, segmentId).attributes('disabled') + ).toBeDefined(); + }); +}); + +describe('segmentation display section', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatSpecImage('img-1'); + await viewImage('img-1'); + }); + + const slider = (wrapper: VueWrapper, label: string) => { + const found = wrapper + .findAllComponents(SliderStub) + .find((candidate) => candidate.props('name') === 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('keeps a change made before the image has a segmentation', async () => { + const wrapper = mountList(); + + await setSlider(wrapper, 'Fill Opacity', 0.25); + + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).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', 0.25], + ['Outline Opacity', 0.5], + ['Outline Thickness', 4], + ] as const)( + 'writes %s onto the viewed image’s segmentation', + async (label, 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(slider(wrapper, label).attributes('data-value')).toBe( + String(value) + ); + } + ); + + it('writes only the viewed image’s segmentation', async () => { + await seatSpecImage('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); + await viewImage('img-2'); + + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( + String(DEFAULT_SEGMENTATION_FILL_OPACITY) + ); + await viewImage('img-1'); + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( + '0.25' + ); + }); +}); + +// 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: Index3 = [4, 4, 8]; + + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1', { name: 'CT', dimensions: 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 seatSpecImage('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'); + 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]); + }); + + 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 seatSpecImage('img-1'); + await seatSpecImage('img-2'); + await viewImage('img-1'); + }); + + const protectedContent = () => { + const segmentId = segments().addSegment({ name: 'Tumor' }); + ['img-1', 'img-2'].forEach((imageID) => { + seedVoxel(maskOn(imageID, segmentId).id, [1, 1, 0]); + useRulerStore().addTool({ + imageID, + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + }); + return segmentId; + }; + + // The row keeps its name and still reveals what it holds on both images. + const expectPreserved = async (wrapper: VueWrapper, segmentId: string) => { + for (const imageID of ['img-2', 'img-1']) { + await viewImage(imageID); + expect(rowShown(wrapper, segmentId).name).toBe('Tumor'); + expect( + revealButton(wrapper, segmentId).attributes('disabled') + ).toBeUndefined(); + } + }; + + it('disables color, edit and delete consistently and restores editing after unlocking', async () => { + const segmentId = 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); + await expectPreserved(wrapper, segmentId); + } + 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(rowShown(wrapper, segmentId).name).toBe('Lesion'); + }); + + it.each(['done', 'delete'])( + 'refuses %s if the segment becomes locked while its editor is open', + async (action) => { + const segmentId = 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(); + await expectPreserved(wrapper, segmentId); + } + ); +}); + +// --------------------------------------------------------------------------- +// 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 seatSpecImage('img-1'); + await seatSpecImage('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(rowIds(wrapper)).toEqual([]); + 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(rowIds(wrapper)).toEqual([]); + 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(rowIds(wrapper)).toEqual([]); + 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 seatSpecImage('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(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..06b72f4a5 --- /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 segment', async () => { + const first = paintMask('img-1', 'Tumor'); + const second = store().ensureSegmentationForImage('img-2'); + const onSecond = store().createMask( + second.id, + mintSegment({ name: 'Node' }) + ); + // The selected segment 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/__tests__/useSegmentEditing.spec.ts b/src/segmentation/composables/__tests__/useSegmentEditing.spec.ts new file mode 100644 index 000000000..4fd62d251 --- /dev/null +++ b/src/segmentation/composables/__tests__/useSegmentEditing.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +import { createSegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { useSegmentEditing } from '@/src/segmentation/composables/useSegmentEditing'; + +// An appearance field a segment does not state follows the app default, so a +// commit writes back only the fields the edit changed. +describe('committing a segment edit', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + const editSegment = () => { + const registry = createSegmentRegistry(); + const id = registry.addSegment({ name: 'Tumor' }); + const editing = useSegmentEditing(registry); + editing.startEditing(id); + return { registry, id, editing }; + }; + + it('leaves the fields a rename did not touch unstated', () => { + const { registry, id, editing } = editSegment(); + const { color } = registry.getSegment(id)!; + + editing.editState.name = ' Lesion '; + editing.stopEditing(true); + + expect(registry.getSegment(id)).toEqual({ + id, + name: 'Lesion', + color, + visible: true, + locked: false, + }); + }); + + it('states a changed field and keeps a field changed back unstated', () => { + const { registry, id, editing } = editSegment(); + + editing.editState.fillOpacity = 0.5; + editing.editState.strokeWidth += 1; + editing.editState.strokeWidth -= 1; + editing.stopEditing(true); + + expect(registry.getSegment(id)).toMatchObject({ fillOpacity: 0.5 }); + expect(registry.getSegment(id)).not.toHaveProperty('strokeWidth'); + expect(registry.getSegment(id)).not.toHaveProperty('outlineOpacity'); + }); +}); diff --git a/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts b/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts new file mode 100644 index 000000000..74e7ea3fb --- /dev/null +++ b/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { useSegmentShapes } from '@/src/segmentation/composables/useSegmentShapes'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useViewStore } from '@/src/store/views'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { AXIAL_FRAME_OF_REFERENCE } from '@/src/utils/frameOfReference'; + +const seat = (id: string) => + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + +describe('segment shapes', () => { + beforeEach(() => { + setActivePinia(createPinia()); + seat('img-1'); + seat('img-2'); + useViewStore().setDataForAllViews('img-1'); + }); + + it('groups the viewed image’s finished shapes under their segments', () => { + const { segments } = useSegmentStore(); + const tumor = segments.mintSegment({ name: 'Tumor' }); + const node = segments.mintSegment({ name: 'Node' }); + const placed = { frameOfReference: AXIAL_FRAME_OF_REFERENCE, slice: 2 }; + const ruler = useRulerStore().addTool({ + ...placed, + imageID: 'img-1', + segmentId: tumor, + firstPoint: [0, 0, 0], + secondPoint: [3, 4, 0], + }); + const rectangle = useRectangleStore().addTool({ + ...placed, + imageID: 'img-1', + segmentId: node, + }); + useRulerStore().addTool({ ...placed, imageID: 'img-2', segmentId: tumor }); + useRulerStore().addTool({ + ...placed, + imageID: 'img-1', + segmentId: node, + placing: true, + }); + + const { shapesOf } = useSegmentShapes(); + const listed = (segmentId: string) => + shapesOf(segmentId).map(({ id, placement, measurement }) => ({ + id, + placement, + measurement, + })); + + expect(listed(tumor)).toEqual([ + { id: ruler, placement: 'Axial 3', measurement: '5.00mm' }, + ]); + expect(listed(node)).toEqual([ + { id: rectangle, placement: 'Axial 3', measurement: '' }, + ]); + }); +}); 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..a31c227bc --- /dev/null +++ b/src/segmentation/composables/useSegmentEditing.ts @@ -0,0 +1,106 @@ +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 { cleanUndefined } from '@/src/utils'; +import { deleteSegmentAndReport } from '@/src/segmentation/deleteSegment'; + +/** + * The segment edit dialog: 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, + }); + // A commit states only what changed, so an untouched field keeps following + // the app default. + let opened = { ...editState }; + + 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((segment) => segment.id !== editingSegmentId.value) + .map((segment) => registry.appearanceOf(segment.id).name.trim()) + ) + ); + + function startEditing(id: string) { + const appearance = registry.appearanceOf(id); + if (!registry.getSegment(id) || appearance.locked) return; + 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; + opened = { ...editState }; + } + + function stopEditing(commit: boolean) { + const id = editingSegmentId.value; + if ( + id && + commit && + registry.getSegment(id) && + !registry.appearanceOf(id).locked + ) { + const changed = (key: K) => + editState[key] === opened[key] ? undefined : editState[key]; + const color = changed('color'); + registry.updateSegment( + id, + cleanUndefined({ + name: changed('name')?.trim(), + color: color === undefined ? undefined : cssColorToRGBA(color), + fillOpacity: changed('fillOpacity'), + outlineOpacity: changed('outlineOpacity'), + strokeWidth: changed('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) deleteSegmentAndReport(registry, id); + stopEditing(false); + } + + return { + editDialog, + editState, + editingSegment, + editingName, + editingLocked: computed( + () => registry.appearanceOf(editingSegmentId.value).locked + ), + invalidNames, + startEditing, + stopEditing, + deleteEditingSegment, + }; +} diff --git a/src/segmentation/composables/useSegmentShapes.ts b/src/segmentation/composables/useSegmentShapes.ts new file mode 100644 index 000000000..96b123d3f --- /dev/null +++ b/src/segmentation/composables/useSegmentShapes.ts @@ -0,0 +1,98 @@ +import { defineStore } from 'pinia'; +import { computed, markRaw } 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 { LPSAxis } from '@/src/types/lps'; +import type { Maybe } from '@/src/types'; + +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 = ( + frame: Maybe, + axis: Maybe, + slice: number +) => + frame != null ? `Frame ${frame + 1}` : `${axis ?? 'unknown'} ${slice + 1}`; + +// One shape list for every sidebar reader, so a ruler drag rebuilds it once. +const useSegmentShapesStore = defineStore('segmentShapes', () => { + // Global: a shared store must not take the image of whichever view built it. + const { currentImageID, currentImageMetadata } = useCurrentImage('global'); + + const rulers = useRulerStore(); + + const shapes = computed(() => + SHAPE_TOOLS.flatMap(({ type, icon }) => { + const store = useAnnotationToolStore(type); + return store.finishedTools + .filter((tool) => tool.imageID === currentImageID.value) + .map((tool) => { + const axis = frameOfReferenceToImageSliceAndAxis( + tool.frameOfReference, + currentImageMetadata.value, + { allowOutOfBoundsSlice: true } + )?.axis; + return { + id: tool.id, + type, + icon, + segmentId: tool.segmentId, + hidden: !!tool.hidden, + axis, + slice: tool.slice, + frame: tool.frame, + placement: placement(tool.frame, axis, tool.slice), + // 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) ?? []; + + // Raw, so a consumer holds the computed ref rather than its unwrapped value. + return { segmentShapes: markRaw({ shapes, shapesOf }) }; +}); + +/** + * 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 const useSegmentShapes = () => useSegmentShapesStore().segmentShapes; diff --git a/src/segmentation/deleteSegment.ts b/src/segmentation/deleteSegment.ts new file mode 100644 index 000000000..e5181ba50 --- /dev/null +++ b/src/segmentation/deleteSegment.ts @@ -0,0 +1,59 @@ +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) { + // A segment has at most one mask per image, so this counts both. + const images = Object.values(useSegmentationStore().segmentations).filter( + (segmentation) => + Object.values(segmentation.masks).some( + (mask) => mask.segmentId === segmentId && maskHasContent(mask) + ) + ).length; + const annotations = Object.values(AnnotationToolStoreMap).reduce( + (total, useStore) => + total + + useStore().finishedTools.filter((tool) => tool.segmentId === segmentId) + .length, + 0 + ); + return { images, 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) || + registry.appearanceOf(segmentId).locked + ) + return; + const { images, annotations } = countCascade(segmentId); + registry.deleteSegment(segmentId); + + const removed = [ + images > 0 && + `${images} ${plural(images, '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/editing/__tests__/processWorker.spec.ts b/src/segmentation/editing/__tests__/processWorker.spec.ts new file mode 100644 index 000000000..95d8e1104 --- /dev/null +++ b/src/segmentation/editing/__tests__/processWorker.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { terminateProcessWorkers } from '@/src/segmentation/editing/processWorker'; +import { hostOverSilentWorkers } from '@/src/segmentation/editing/__tests__/silentWorker'; + +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('keeps separate hosts and their endpoints independent', async () => { + const first = hostOverSilentWorkers(); + const second = hostOverSilentWorkers(); + const firstCall = first.host.call((api) => api.smooth(1)); + const secondCall = second.host.call((api) => api.smooth(2)); + + expect(first.workers).toHaveLength(1); + expect(second.workers).toHaveLength(1); + first.host.terminate(); + await expect(firstCall).rejects.toThrow(); + expect(first.workers[0].terminated).toBe(true); + expect(second.workers[0].terminated).toBe(false); + + second.host.terminate(); + await expect(secondCall).rejects.toThrow(); + }); + + 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..5bb648e7a --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts @@ -0,0 +1,408 @@ +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], +]; + +// fillPoly fills i in 1..5 on rows j 2..3 and i in 1..3 on rows j 4..5. +const L_SHAPE: Vector3[] = [ + [1, 1, 0], + [5, 1, 0], + [5, 3, 0], + [3, 3, 0], + [3, 5, 0], + [1, 5, 0], +]; +const L_NOTCH: Index3[] = [ + [4, 4, 0], + [5, 4, 0], + [4, 5, 0], + [5, 5, 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, L_SHAPE); + + // The notch lies inside the mask's box, so each probe reads a stored 0. + expect(L_NOTCH.map((index) => maskValueAt(maskId, index))).toEqual([ + 0, 0, 0, 0, + ]); + expect(markedVoxels(maskId)).toHaveLength(16); + }); + + 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('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 segment 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 segment the polygon names, not the selected one', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + const node = segments().addSegment({ name: 'Node' }); + // The user picks another segment between placing the polygon and rasterizing + // it; the polygon still carries the segment 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 segment 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 segment 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..c3decc139 --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + seatSpecImage as seatImage, + maskOn, + lockSegment, + store, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +import { + rasterizeTargetDisabledReason, + resolveRasterizeTarget, +} from '@/src/segmentation/editing/rasterizePolygon'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +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(store().boundMaskIds('img-1')).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 segment 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().boundMaskIds('img-1')).toHaveLength(1); + }); + + it('leaves the selected segment 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 segment 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().boundMaskIds('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 allocating it', async () => { + await seatImage('img-1'); + const first = makeMask('img-1', 'First'); + lockSegment(first.record.id, true); + + expect(rasterizeTargetDisabledReason('')).toBe( + 'Unlock this segment to rasterize into it' + ); + expect( + store().getMask(first.record.id).representations.labelmap + ).toBeUndefined(); + }); + + it('rasterizes into a minted segment 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( + 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] = SEGMENT_VALUE; + + expect( + store() + .findMaskBinding(target.maskId)! + .image.getPointData() + .getScalars() + .getData()[3] + ).toBe(SEGMENT_VALUE); + }); + + it('rasterizes into a minted segment 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 segment'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..7986aa9db --- /dev/null +++ b/src/segmentation/editing/__tests__/silentWorker.ts @@ -0,0 +1,49 @@ +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; + +// A failed or abandoned worker accepts calls without ever replying. +function silentEndpoint() { + const listeners = new Map void>>(); + const posted: unknown[] = []; + let terminated = false; + + return { + posted, + get terminated() { + return terminated; + }, + addEventListener(type: string, listener: (event: unknown) => void) { + const forType = listeners.get(type) ?? []; + listeners.set(type, [...forType, listener]); + }, + removeEventListener(type: string, listener: (event: unknown) => void) { + const forType = listeners.get(type) ?? []; + listeners.set( + type, + forType.filter((entry) => entry !== listener) + ); + }, + postMessage(message: unknown) { + posted.push(message); + }, + terminate() { + terminated = true; + }, + emit(event: { type: string; message?: string }) { + [...(listeners.get(event.type) ?? [])].forEach((listener) => + listener(event) + ); + }, + }; +} + +export type SilentApi = { smooth: (value: number) => Promise }; + +export function hostOverSilentWorkers() { + const workers: ReturnType[] = []; + const host = createProcessWorkerHost(() => { + const worker = silentEndpoint(); + workers.push(worker); + return worker as unknown as Worker; + }); + return { host, workers }; +} diff --git a/src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts b/src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts new file mode 100644 index 000000000..97d176533 --- /dev/null +++ b/src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { fillHoles } from '@/src/segmentation/editing/algorithms/fillHoles'; +import { fillHolesWorker } from '@/src/segmentation/editing/algorithms/fillHoles.worker'; + +// 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. +function flatFromGrid(grid: number[][]) { + const dimI = grid[0].length; + const dimJ = grid.length; + const data = new Uint8Array(grid.flat()); + 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 }).out; + +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([ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], + ]); + const out = fillAxialSlice(data, dimensions); + expect(Array.from(out)).toEqual( + flatOf([ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]) + ); + }); + + it('leaves border-connected background untouched', () => { + const { data, dimensions } = flatFromGrid([ + [0, 1, 1], + [1, 0, 1], + [1, 1, 1], + ]); + 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( + flatOf([ + [0, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]) + ); + }); + + it('does not mutate the input array', () => { + const { data, dimensions } = flatFromGrid([ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], + ]); + const before = Array.from(data); + fillAxialSlice(data, dimensions); + expect(Array.from(data)).toEqual(before); + }); + + it('fills enclosed holes while leaving disconnected foreground alone', () => { + const { data, dimensions } = flatFromGrid([ + [1, 1, 1, 1, 1, 0, 1], + [1, 0, 1, 0, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + [1, 0, 1, 0, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + ]); + const out = fillAxialSlice(data, dimensions); + expect(Array.from(out)).toEqual( + flatOf([ + [1, 1, 1, 1, 1, 0, 1], + [1, 1, 1, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + ]) + ); + }); + + it('fills the gap around an island', () => { + const { data, dimensions } = flatFromGrid([ + [1, 1, 1, 1, 1], + [1, 0, 0, 0, 1], + [1, 0, 1, 0, 1], + [1, 0, 0, 0, 1], + [1, 1, 1, 1, 1], + ]); + const out = fillAxialSlice(data, dimensions); + expect(Array.from(out)).toEqual( + flatOf([ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + ]) + ); + }); + + it('counts the voxels it filled', () => { + const { data, dimensions } = flatFromGrid([ + [1, 1, 1, 1, 0], + [1, 0, 1, 1, 0], + [1, 0, 0, 1, 0], + [1, 1, 1, 1, 0], + ]); + expect(fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }).filled).toBe( + 3 + ); + }); + + it('whole-volume on a non-default axis fills every slice', () => { + // dims [3,3,3], slicing along axis 0: each i-plane is the (j,k) plane. + // Every i-plane is a ring of 1 around a 0 at (j=1, k=1). + const dimensions: [number, number, number] = [3, 3, 3]; + const data = new Uint8Array(27).fill(1); + const holeOffset = (i: number) => i + 1 * 3 + 1 * 9; // j=1, k=1 + for (let i = 0; i < 3; i += 1) data[holeOffset(i)] = 0; + // A border voxel that must stay 0 (corner of the i=0 plane). + data[0] = 0; + + const { out } = fillHoles({ data, dimensions, axis: 0 }); + for (let i = 0; i < 3; i += 1) { + expect(out[holeOffset(i)]).toBe(1); + } + expect(out[0]).toBe(0); + }); + + it('only fills the requested slice when sliceIndex is given', () => { + const dimensions: [number, number, number] = [3, 3, 3]; + const data = new Uint8Array(27).fill(1); + 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); + }); +}); + +describe('fillHolesWorker', () => { + const run = (grid: number[][]) => + fillHolesWorker({ + ...flatFromGrid(grid), + axis: 2, + sliceIndex: 0, + }); + + it('hands back nothing when no hole is enclosed', () => { + // A process reads no result as "nothing to do", so the mask is not + // rewritten and no preview opens between two identical states. + expect( + run([ + [1, 1, 1], + [1, 0, 0], + [1, 1, 1], + ]) + ).toBeUndefined(); + }); + + it('hands back the filled copy when a hole is filled', () => { + const out = run([ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], + ]); + expect(Array.from(out ?? [])).toEqual( + flatOf([ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], + ]) + ); + }); +}); diff --git a/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts b/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts index 4a809e805..de498e1ed 100644 --- a/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts +++ b/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import { fullExtent, type Extent3D } from '@/src/segmentation/geometry'; import { gaussianSmoothLabelMapWorker } from '@/src/segmentation/editing/algorithms/gaussianSmooth.worker'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; -const LABEL = 3; type Dims = [number, number, number]; /** Reconstructs the whole parent, including growth outside the input mask. */ @@ -18,8 +18,8 @@ function smooth( spacing: [1, 1, 1], maskExtent, parentDimensions, - params: { sigma: 1, label: LABEL }, - }); + params: { sigma: 1 }, + })!; const [pi, pj, pk] = parentDimensions; const parent = new Uint8Array(pi * pj * pk); let offset = 0; @@ -39,7 +39,7 @@ function boxInParent(extent: Extent3D, dimensions: Dims) { for (let k = extent[4]; k <= extent[5]; k += 1) { for (let j = extent[2]; j <= extent[3]; j += 1) { for (let i = extent[0]; i <= extent[1]; i += 1) { - parent[i + pi * (j + pj * k)] = LABEL; + parent[i + pi * (j + pj * k)] = SEGMENT_VALUE; } } } @@ -49,7 +49,7 @@ function boxInParent(extent: Extent3D, dimensions: Dims) { describe('gaussianSmoothLabelMapWorker', () => { it('smooths an isolated voxel away even when the mask is that one voxel', () => { const smoothed = smooth( - new Uint8Array([LABEL]), + new Uint8Array([SEGMENT_VALUE]), [1, 1, 1], [1, 1, 1, 1, 1, 1], [3, 3, 3] @@ -74,7 +74,7 @@ describe('gaussianSmoothLabelMapWorker', () => { ]; const data = new Uint8Array( dimensions[0] * dimensions[1] * dimensions[2] - ).fill(LABEL); + ).fill(SEGMENT_VALUE); const croppedResult = smooth(data, dimensions, extent, parentDimensions); const parentResult = smooth( boxInParent(extent, parentDimensions), @@ -86,37 +86,49 @@ describe('gaussianSmoothLabelMapWorker', () => { it('retains all 62 voxels when mirroring grows a box toward parent faces', () => { const smoothed = smooth( - new Uint8Array(64).fill(LABEL), + new Uint8Array(64).fill(SEGMENT_VALUE), [4, 4, 4], [1, 4, 1, 4, 1, 4], [9, 9, 9] ); - expect(smoothed.filter((value) => value === LABEL)).toHaveLength(62); - expect(smoothed[0 + 2 * 9 + 2 * 81]).toBe(LABEL); + expect(smoothed.filter((value) => value === SEGMENT_VALUE)).toHaveLength( + 62 + ); + expect(smoothed[0 + 2 * 9 + 2 * 81]).toBe(SEGMENT_VALUE); }); - it('erodes a cropped cube at its corners and keeps its centre', () => { + it('erodes a cropped cube at its corners and keeps its center', () => { const smoothed = smooth( - new Uint8Array(27).fill(LABEL), + new Uint8Array(27).fill(SEGMENT_VALUE), [3, 3, 3], [1, 3, 1, 3, 1, 3], [5, 5, 5] ); expect(smoothed[1 + 1 * 5 + 1 * 25]).toBe(0); - expect(smoothed[2 + 2 * 5 + 2 * 25]).toBe(LABEL); + expect(smoothed[2 + 2 * 5 + 2 * 25]).toBe(SEGMENT_VALUE); }); it('mirrors only at parent faces, not at the mask allocation', () => { - const data = new Uint8Array(27).fill(LABEL); + const data = new Uint8Array(27).fill(SEGMENT_VALUE); const againstFace = smooth(data, [3, 3, 3], [0, 2, 2, 4, 2, 4], [9, 9, 9]); const awayFromFace = smooth(data, [3, 3, 3], [1, 3, 2, 4, 2, 4], [9, 9, 9]); - expect(againstFace[0 + 3 * 9 + 3 * 81]).toBe(LABEL); + expect(againstFace[0 + 3 * 9 + 3 * 81]).toBe(SEGMENT_VALUE); 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 nothing marked 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(4), + dimensions, + spacing: [1, 1, 1], + maskExtent: fullExtent(dimensions), + parentDimensions: dimensions, + params: { sigma: 1 }, + }) + ).toBeUndefined(); }); }); diff --git a/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts b/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts index be65c6a3d..b0a396b87 100644 --- a/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts +++ b/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from 'vitest'; import { gaussianSmoothLabelMapWorker } from '@/src/segmentation/editing/algorithms/gaussianSmooth.worker'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; // A byte-exact record of the filter's output. The rest of the smoothing suite // asserts on shape properties, which a change in the arithmetic can satisfy // while every voxel moves; this catches that. // -// Each entry is one row of the volume, '1' where the label survives, so a +// Each entry is one row of the volume, '1' where the segment survives, so a // failure names the rows that moved. To refresh one after a deliberate change -// in behaviour, print `asRows(smooth(...))`. +// in behavior, print `asRows(smooth(...))`. -const LABEL = 3; const DIMENSIONS: [number, number, number] = [9, 8, 7]; const SIGMA_0_6 = [ @@ -193,7 +193,7 @@ const blob = () => { const [di, dj, dk] = DIMENSIONS; const data = new Uint8Array(di * dj * dk); const mark = (i: number, j: number, k: number) => { - data[i + j * di + k * di * dj] = LABEL; + data[i + j * di + k * di * dj] = SEGMENT_VALUE; }; for (let k = 1; k <= 5; k += 1) { for (let j = 1; j <= 5; j += 1) { @@ -214,11 +214,11 @@ const smooth = (sigma: number, spacing: [number, number, number]) => spacing, maskExtent: [0, 8, 0, 7, 0, 6], parentDimensions: DIMENSIONS, - params: { sigma, label: LABEL }, - }).scalars; + params: { sigma }, + })!.scalars; const asRows = (output: ArrayLike) => { - const bits = Array.from(output, (value) => (value ? '1' : '0')).join(''); + const bits = Array.from(output, String).join(''); return bits.match(new RegExp(`.{${DIMENSIONS[0]}}`, 'g')); }; diff --git a/src/segmentation/editing/algorithms/fillHoles.ts b/src/segmentation/editing/algorithms/fillHoles.ts index f8f24deba..a7373a6a0 100644 --- a/src/segmentation/editing/algorithms/fillHoles.ts +++ b/src/segmentation/editing/algorithms/fillHoles.ts @@ -1,4 +1,5 @@ import { TypedArray } from '@kitware/vtk.js/types'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; // 4-connected neighbor offsets, shared so the flood-fill loops never allocate // a neighbor array per visited voxel. @@ -8,19 +9,15 @@ const NEIGHBOR_DV = [0, 0, -1, 1]; type MaskData = TypedArray | number[]; export type FillHolesOptions = { - // Flat label-map scalar array, indexed as i + j*dimI + k*dimI*dimJ. + // One segment's flat mask, indexed as i + j*dimI + k*dimI*dimJ. data: MaskData; - // Label-map IJK dimensions [dimI, dimJ, dimK]. + // Mask IJK dimensions [dimI, dimJ, dimK]. dimensions: [number, number, number]; // IJK axis perpendicular to the fill plane (the slice axis). axis: 0 | 1 | 2; // When set, only this slice index along `axis` is processed. // When omitted, every slice along `axis` is processed. sliceIndex?: number; - // The one label treated as foreground, and the one enclosed background is - // filled with. Storage is a mask per segment, so a fill is always one - // segment's own; running over several segments is one call each. - label: number; }; type Plane = { @@ -31,11 +28,10 @@ type Plane = { // `visited` and `stack` are reused across slices, so the whole run allocates // them once. -// `visited`: 0 = unvisited, 1 = outside (border-connected), 2 = hole. +// `visited`: 0 = unvisited, 1 = outside (border-connected). type Flood = { plane: Plane; out: MaskData; - label: number; visited: Uint8Array; stack: number[]; }; @@ -43,23 +39,21 @@ type Flood = { const inPlane = (plane: Plane, u: number, v: number) => u >= 0 && u < plane.uDim && v >= 0 && v < plane.vDim; -// Drain `stack`, expanding the region into unvisited non-foreground neighbors -// (each marked with `mark`). `collect`, when given, receives the flat offset of -// every region cell. -function drain(flood: Flood, mark: number, collect?: number[]) { - const { plane, out, label, visited, stack } = flood; +// Drain `stack`, expanding the outside region into unvisited non-foreground +// neighbors. +function drain(flood: Flood) { + const { plane, out, visited, stack } = flood; while (stack.length) { const p = stack.pop()!; const u = p % plane.uDim; const v = (p - u) / plane.uDim; - if (collect) collect.push(plane.offset(u, v)); for (let n = 0; n < 4; n++) { const nu = u + NEIGHBOR_DU[n]; const nv = v + NEIGHBOR_DV[n]; if (!inPlane(plane, nu, nv)) continue; const np = nu + nv * plane.uDim; - if (out[plane.offset(nu, nv)] !== label && visited[np] === 0) { - visited[np] = mark; + if (out[plane.offset(nu, nv)] !== SEGMENT_VALUE && visited[np] === 0) { + visited[np] = 1; stack.push(np); } } @@ -69,10 +63,10 @@ function drain(flood: Flood, mark: number, collect?: number[]) { // Flood the non-foreground cells reachable from the slice border, marking them // "outside". Whatever it does not reach is enclosed. function markOutside(flood: Flood) { - const { plane, out, label, visited, stack } = flood; + const { plane, out, visited, stack } = flood; const seed = (u: number, v: number) => { const p = u + v * plane.uDim; - if (visited[p] === 0 && out[plane.offset(u, v)] !== label) { + if (visited[p] === 0 && out[plane.offset(u, v)] !== SEGMENT_VALUE) { visited[p] = 1; stack.push(p); } @@ -85,43 +79,31 @@ function markOutside(flood: Flood) { seed(0, v); seed(plane.uDim - 1, v); } - drain(flood, 1); + drain(flood); } -// Only fill background. A voxel another segment holds is not this segment's to -// take here; the write path decides that on confirm. -function fillBackground(out: MaskData, cells: number[], label: number) { - for (let c = 0; c < cells.length; c++) { - if (out[cells[c]] === 0) { - out[cells[c]] = label; - } - } -} - -// Any non-foreground cell not marked "outside" is part of a hole. Group each -// hole into a connected component and fill it. -function fillEnclosed(flood: Flood) { - const { plane, out, label, visited, stack } = flood; +// Background the border flood did not reach is enclosed. Returns how many +// voxels it filled. +function fillEnclosed({ plane, out, visited }: Flood) { + let filled = 0; for (let v = 0; v < plane.vDim; v++) { for (let u = 0; u < plane.uDim; u++) { - const p = u + v * plane.uDim; - if (visited[p] !== 0 || out[plane.offset(u, v)] === label) continue; - - const holeCells: number[] = []; - visited[p] = 2; - stack.push(p); - drain(flood, 2, holeCells); - fillBackground(out, holeCells, label); + const offset = plane.offset(u, v); + if (visited[u + v * plane.uDim] === 0 && out[offset] === 0) { + out[offset] = SEGMENT_VALUE; + filled += 1; + } } } + return filled; } -// Fills enclosed background regions ("holes") on 2D slices of a label map. +// Fills enclosed background regions ("holes") on 2D slices of a mask. // A hole is background that does not connect to the slice border. Only -// background (0) voxels are filled. Returns a copy of `data`; the input is left -// untouched. +// background (0) voxels are filled. Returns a copy of `data` and how many +// voxels were filled; the input is left untouched. export function fillHoles(opts: FillHolesOptions) { - const { data, dimensions, axis, sliceIndex, label } = opts; + const { data, dimensions, axis, sliceIndex } = opts; const out = data.slice(); const strides = [1, dimensions[0], dimensions[0] * dimensions[1]]; @@ -138,6 +120,7 @@ export function fillHoles(opts: FillHolesOptions) { const visited = new Uint8Array(uDim * vDim); const stack: number[] = []; + let filled = 0; const firstSlice = sliceIndex ?? 0; const lastSlice = sliceIndex ?? sliceCount - 1; @@ -152,14 +135,13 @@ export function fillHoles(opts: FillHolesOptions) { offset: (u, v) => base + u * uStride + v * vStride, }, out, - label, visited, stack, }; markOutside(flood); - fillEnclosed(flood); + filled += fillEnclosed(flood); } - return out; + return { out, filled }; } diff --git a/src/segmentation/editing/algorithms/fillHoles.worker.ts b/src/segmentation/editing/algorithms/fillHoles.worker.ts index 68a1ccdb1..bb7f44e7c 100644 --- a/src/segmentation/editing/algorithms/fillHoles.worker.ts +++ b/src/segmentation/editing/algorithms/fillHoles.worker.ts @@ -8,8 +8,7 @@ import { // freeze the UI, mirroring gaussianSmooth.worker.ts. Undefined when no hole was // filled, which is the process's "nothing to do". export function fillHolesWorker(input: FillHolesOptions) { - const out = fillHoles(input); - const filled = out.some((value, index) => value !== input.data[index]); + const { out, filled } = fillHoles(input); if (!filled) return undefined; // Moved back rather than cloned, as the input was moved in. return Comlink.transfer( diff --git a/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts b/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts index 21c02087e..4490c0e42 100644 --- a/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts +++ b/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts @@ -1,18 +1,27 @@ import * as Comlink from 'comlink'; import { TypedArray } from '@kitware/vtk.js/types'; -import { createTypedArrayLike } from '@/src/utils'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; import { + clipExtent, extentSize, extentUnion, + extentWithin, + fullExtent, + isEmptyExtent, + markedExtent, + padExtent, + reframeMaskScalars, type Extent3D, } from '@/src/segmentation/geometry'; -export type GaussianSmoothParams = { +// The kernel's reach and the padding grown for it must agree. +const RADIUS_FACTOR = 1.5; + +type GaussianSmoothParams = { sigma: number; - label: number; }; -export type GaussianSmoothInput = { +type GaussianSmoothInput = { data: TypedArray | number[]; dimensions: number[]; spacing: [number, number, number]; @@ -21,8 +30,8 @@ export type GaussianSmoothInput = { params: GaussianSmoothParams; }; -function generateGaussianKernel(sigma: number, radiusFactor = 1.5) { - const radius = Math.ceil(sigma * radiusFactor); +function generateGaussianKernel(sigma: number) { + const radius = Math.ceil(sigma * RADIUS_FACTOR); const size = 2 * radius + 1; const kernel = new Float32Array(size); const center = radius; @@ -125,13 +134,12 @@ function convolve1D( function gaussianFilter3D( inputData: TypedArray | number[], dimensions: number[], - sigmaPixels: [number, number, number], - radiusFactor = 1.5 + sigmaPixels: [number, number, number] ) { const totalSize = dimensions[0] * dimensions[1] * dimensions[2]; - const kernelX = generateGaussianKernel(sigmaPixels[0], radiusFactor); - const kernelY = generateGaussianKernel(sigmaPixels[1], radiusFactor); - const kernelZ = generateGaussianKernel(sigmaPixels[2], radiusFactor); + const kernelX = generateGaussianKernel(sigmaPixels[0]); + const kernelY = generateGaussianKernel(sigmaPixels[1]); + const kernelZ = generateGaussianKernel(sigmaPixels[2]); const temp = new Float32Array(totalSize); const output = new Float32Array(totalSize); @@ -142,89 +150,6 @@ function gaussianFilter3D( return output; } -// What a bounding-box scan holds still: the voxels being read, the bounds -// being widened, and the row addressing. Built once, so scanning a row -// allocates nothing. -type RowScan = { - data: TypedArray | number[]; - bounds: number[]; - dimX: number; - sliceSize: number; - label: number; -}; - -// Its own function so that the per-voxel test sits two blocks deep rather than -// four. -function growBoundsOverRow(scan: RowScan, y: number, z: number) { - const { data, bounds, dimX, sliceSize, label } = scan; - const rowStart = y * dimX + z * sliceSize; - - for (let x = 0; x < dimX; x++) { - if (data[rowStart + x] !== label) continue; - bounds[0] = Math.min(bounds[0], x); - bounds[1] = Math.max(bounds[1], x); - bounds[2] = Math.min(bounds[2], y); - bounds[3] = Math.max(bounds[3], y); - bounds[4] = Math.min(bounds[4], z); - bounds[5] = Math.max(bounds[5], z); - } -} - -function calculateBoundingBox( - data: TypedArray | number[], - dimensions: number[], - label: number -) { - const [dimX, dimY, dimZ] = dimensions; - const bounds = [dimX, -1, dimY, -1, dimZ, -1]; - const scan: RowScan = { - data, - bounds, - dimX, - sliceSize: dimX * dimY, - label, - }; - - for (let z = 0; z < dimZ; z++) { - for (let y = 0; y < dimY; y++) { - growBoundsOverRow(scan, y, z); - } - } - - if (bounds[1] === -1) return null; - - return bounds; -} - -function expandBoundingBox({ - bounds, - maskExtent, - parentDimensions, - sigmaPixels, - radiusFactor = 1.5, -}: { - bounds: number[]; - maskExtent: GaussianSmoothInput['maskExtent']; - parentDimensions: GaussianSmoothInput['parentDimensions']; - sigmaPixels: [number, number, number]; - radiusFactor?: number; -}) { - return sigmaPixels.flatMap((sigma, axis) => { - const padding = Math.ceil(sigma * radiusFactor); - // The parent-image faces, stated in mask coordinates. Ending the - // convolution volume there keeps the established mirrored boundary - // wherever the mask sits, so the result does not depend on how much of - // the parent the mask happens to be allocated over. Crop faces are not - // clamped: outside the buffer reads as background. - const parentLow = -maskExtent[axis * 2]; - const parentHigh = parentDimensions[axis] - 1 - maskExtent[axis * 2]; - return [ - Math.max(parentLow, bounds[axis * 2] - padding), - Math.min(parentHigh, bounds[axis * 2 + 1] + padding), - ]; - }); -} - /** * Visits every voxel of `bounds` that the volume actually holds, giving each * its offset in the volume and its offset in the padded sub-volume. The @@ -254,16 +179,15 @@ function forEachClippedVoxel( } /** - * The label's own binary mask over `bounds`, which is the only thing the - * filter reads. Built in one pass rather than copying the labels out and - * thresholding them afterwards: the copy is a second volume-sized Float32 + * The segment's binary mask over `bounds`, which is the only thing the + * filter reads. Built in one pass rather than copying the mask out and + * thresholding it afterwards: the copy is a second volume-sized Float32 * array, live at the same time as this one. */ function extractSubMask( data: TypedArray | number[], dimensions: number[], - bounds: number[], - label: number + bounds: number[] ) { const [minX, maxX, minY, maxY, minZ, maxZ] = bounds; const subDims = [maxX - minX + 1, maxY - minY + 1, maxZ - minZ + 1]; @@ -271,7 +195,7 @@ function extractSubMask( const subMask = new Float32Array(subDims[0] * subDims[1] * subDims[2]); forEachClippedVoxel(dimensions, bounds, (origIndex, subIndex) => { - subMask[subIndex] = data[origIndex] === label ? 255.0 : 0.0; + subMask[subIndex] = data[origIndex] === SEGMENT_VALUE ? 255.0 : 0.0; }); return { subMask, subDims }; @@ -282,17 +206,13 @@ function extractSubMask( function copySubVolumeBack( subData: Float32Array, originalData: TypedArray | number[], - region: { dimensions: number[]; bounds: number[] }, - label: number + region: { dimensions: number[]; bounds: number[] } ) { forEachClippedVoxel( region.dimensions, region.bounds, (origIndex, subIndex) => { - const origLabel = originalData[origIndex]; - if (origLabel === label || origLabel === 0) { - originalData[origIndex] = subData[subIndex] > 127.5 ? label : 0; - } + originalData[origIndex] = subData[subIndex] > 127.5 ? SEGMENT_VALUE : 0; } ); } @@ -306,7 +226,7 @@ export function gaussianSmoothLabelMapWorker(input: GaussianSmoothInput) { parentDimensions, params, } = input; - const { sigma, label } = params; + const { sigma } = params; if (sigma <= 0) { throw new Error('Sigma must be positive'); @@ -318,67 +238,42 @@ 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. - 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 }; - } - - const expandedBounds = expandBoundingBox({ - bounds, - maskExtent, - parentDimensions, - sigmaPixels, - }); + // Absent when the mask claims no voxel. 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 marked = markedExtent(originalData, maskExtent); + if (isEmptyExtent(marked)) return undefined; + + // Ending the convolution volume at the parent's faces keeps the established + // mirrored boundary wherever the mask sits, so the result does not depend on + // how much of the parent the mask happens to be allocated over. Crop faces + // are not clamped: outside the buffer reads as background. + const smoothedExtent = clipExtent( + padExtent( + marked, + sigmaPixels.map((axisSigma) => Math.ceil(axisSigma * RADIUS_FACTOR)) + ), + fullExtent(parentDimensions) + ); const { subMask, subDims } = extractSubMask( originalData, dimensions, - expandedBounds, - label + extentWithin(smoothedExtent, maskExtent) ); - const smoothedSubMask = gaussianFilter3D(subMask, subDims, sigmaPixels, 1.5); - - const expandedExtent = expandedBounds.map( - (value, axis) => value + maskExtent[axis - (axis % 2)] - ) as Extent3D; - const extent = extentUnion(maskExtent, expandedExtent); - const outputDimensions = extentSize(extent); - const outputData = createTypedArrayLike( - originalData, - outputDimensions[0] * outputDimensions[1] * outputDimensions[2] - ); - const outputBoundsInInput = extent.map( - (value, axis) => value - maskExtent[axis - (axis % 2)] - ); - forEachClippedVoxel( - dimensions, - outputBoundsInInput, - (origIndex, outIndex) => { - outputData[outIndex] = originalData[origIndex]; - } - ); - const smoothedBoundsInOutput = expandedExtent.map( - (value, axis) => value - extent[axis - (axis % 2)] - ); + const smoothedSubMask = gaussianFilter3D(subMask, subDims, sigmaPixels); - copySubVolumeBack( - smoothedSubMask, - outputData, - { dimensions: outputDimensions, bounds: smoothedBoundsInOutput }, - label - ); + const extent = extentUnion(maskExtent, smoothedExtent); + const outputData = reframeMaskScalars(originalData, maskExtent, extent); + copySubVolumeBack(smoothedSubMask, outputData, { + dimensions: extentSize(extent), + bounds: extentWithin(smoothedExtent, extent), + }); // Moved back rather than cloned, as the input was moved in. - return Comlink.transfer( - { scalars: outputData, extent }, - ArrayBuffer.isView(outputData) ? [outputData.buffer as ArrayBuffer] : [] - ); + return Comlink.transfer({ scalars: outputData, extent }, [ + outputData.buffer as ArrayBuffer, + ]); } const workerApi = { diff --git a/src/segmentation/editing/fillBetween.ts b/src/segmentation/editing/fillBetween.ts new file mode 100644 index 000000000..a1915dd99 --- /dev/null +++ b/src/segmentation/editing/fillBetween.ts @@ -0,0 +1,47 @@ +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 { fullExtent, reframeMaskScalars } from '@/src/segmentation/geometry'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +type Interpolate = ( + image: Image, + options: { label: number } +) => Promise<{ outputImage: Image }>; + +export const useFillBetweenStore = defineStore('fillBetween', () => { + async function computeAlgorithm( + target: ProcessTarget, + interpolate: Interpolate = morphologicalContourInterpolation + ) { + const extent = fullExtent(target.parentDimensions); + 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, extent), + }) + ); + // 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: SEGMENT_VALUE }); + 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..4dbff6186 --- /dev/null +++ b/src/segmentation/editing/fillHoles.ts @@ -0,0 +1,120 @@ +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 { extentReachesSlice } from '@/src/segmentation/geometry'; + +export enum FillHolesSliceScope { + CurrentSlice = 'currentSlice', + WholeVolume = 'wholeVolume', +} + +export enum FillHolesSegmentScope { + AllSegments = 'allSegments', + SelectedSegment = 'selectedSegment', +} + +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(viewId: string, target: ProcessTarget, axis: number) { + const { slice } = useViewSliceStore().getConfig(viewId, target.parentImageId); + return extentReachesSlice(target.maskExtent, axis, slice) + ? slice - target.maskExtent[axis * 2] + : undefined; +} + +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.viewInfo.id, 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 detached input is not read again, so move it rather than clone it. + const scalars = await workerHost.call((worker) => + worker.fillHolesWorker( + Comlink.transfer( + { + data, + dimensions, + axis, + sliceIndex, + }, + [data.buffer as ArrayBuffer] + ) + ) + ); + return scalars && { 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..7376bae5c --- /dev/null +++ b/src/segmentation/editing/gaussianSmooth.ts @@ -0,0 +1,57 @@ +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' } + ) +); + +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 input = { + data: target.scalars, + dimensions: target.dimensions, + spacing: target.spacing, + maskExtent: target.maskExtent, + parentDimensions: target.parentDimensions, + params: { sigma: sigma.value }, + }; + // The detached input is not read again, so move it rather than clone it. + return workerHost.call((worker) => + worker.gaussianSmoothLabelMapWorker( + Comlink.transfer(input, [target.scalars.buffer as ArrayBuffer]) + ) + ); + } + + return { + sigma, + setSigma, + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/paintProcess.ts b/src/segmentation/editing/paintProcess.ts new file mode 100644 index 000000000..638ce3414 --- /dev/null +++ b/src/segmentation/editing/paintProcess.ts @@ -0,0 +1,585 @@ +import { defineStore } from 'pinia'; +import { ref, computed, watch } from 'vue'; +import { TypedArray } from '@kitware/vtk.js/types'; +import type { Maybe } from '@/src/types'; +import { arrayEquals, omit } from '@/src/utils'; +import { + LABELMAP_BACKGROUND_VALUE, + maskHasContent, + type VoxelStorage, +} from '@/src/segmentation/model'; +import { + extentContains, + extentSize, + extentUnion, + fullExtent, + isEmptyExtent, + markedExtent, + reframeMaskScalars, + type Extent3D, +} from '@/src/segmentation/geometry'; +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 { useImageCacheStore } from '@/src/store/image-cache'; +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 mask whose segment's 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; +}; + +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); + 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 endRun() { + edits.release(cancelProcess); + processState.value = { step: 'start' }; + paintStore.restoreModeAfterProcess(); + } + + // 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); + } + + /** + * 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 + * neighbor. Masking the result rather than the storage keeps the preview + * honest: what it shows is what confirm leaves behind. Runs once the mask + * has grown to the run's extent, so the run's offsets address it. + */ + function maskVoxelsOtherSegmentsHold(run: PreviewRun) { + const { extent } = run; + // 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', + extent + ); + if (!claimVoxel) return; + + 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, + result: ProcessResult | undefined + ): PreviewRun[] { + if (result === undefined) return []; + const binding = segmentationStore.findMaskBinding(target.maskId); + if (!binding || !arrayEquals(binding.extent, target.maskExtent)) return []; + if (result.scalars === target.voxels.scalars()) { + throw new Error('Process returned the storage buffer it was given'); + } + const extent = previewExtent(target, result); + return [ + { + target, + extent, + originalScalars: arrayEquals(extent, target.maskExtent) + ? target.scalars + : reframeMaskScalars(target.scalars, target.maskExtent, extent), + processedScalars: arrayEquals(extent, result.extent) + ? result.scalars + : reframeMaskScalars(result.scalars, result.extent, extent), + }, + ]; + } + + function confirmProcess() { + 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) + ); + } + endRun(); + } + + 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(); + endRun(); + } + + // 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)) + ); + }); + + // Synchronous invalidation also honors a lock/unlock before Vue's next flush. + watch( + targetLocked, + (locked) => { + if (locked) cancelProcess(); + }, + { 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( + outputs: (ProcessResult | undefined)[], + label: string + ) { + if (!outputs.every((output) => output === undefined)) return; + messageStore.addWarning( + `${label} had nothing to do`, + 'None of the segments it ran on had anything for it to change.' + ); + } + + function targetFor(parentImageId: string, maskId: string) { + const voxels = segmentationStore.maskVoxels(maskId); + const binding = voxels.binding(); + const image = imageCacheStore.getVtkImageData(parentImageId); + if (!binding || isEmptyExtent(binding.extent) || !image) return undefined; + return { + parentImageId, + parentDimensions: [...image.getDimensions()] as [number, number, number], + parentOrigin: Array.from(image.getOrigin()), + maskId, + voxels, + scalars: voxels.snapshot(), + dimensions: extentSize(binding.extent), + spacing: [...binding.image.getSpacing()] as [number, number, number], + direction: Array.from(binding.image.getDirection()), + maskExtent: [...binding.extent] as Extent3D, + } satisfies ResolvedTarget; + } + + // A selected segment with no mask on this image is selected all the same: + // it just holds nothing here yet. + function segmentScopedRefusal(imageId: string) { + if (!segmentRegistry.selectedSegmentId.value) + return 'No segments to process'; + const maskId = segmentationStore.findEditTarget(imageId); + if (maskId && segmentationStore.isLocked(maskId)) + return 'Cannot process locked segment'; + return maskId && maskHasContent(segmentationStore.getMask(maskId)) + ? '' + : 'No segment content to process'; + } + + // 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'; + } + + /** + * Why a run cannot start on this image now, or '' when it can. It copies + * nothing, so a control can ask it on every change. + */ + function startRefusal(imageId: Maybe, requiresActiveSegment = true) { + if (!imageId) return 'No image to process'; + if (requiresActiveSegment) return segmentScopedRefusal(imageId); + return segmentationStore.editableMasks(imageId).length > 0 + ? '' + : nothingEditable(imageId); + } + + function resolveSegmentScoped(imageId: string): ResolvedRun | undefined { + const maskId = segmentationStore.findEditTarget(imageId); + const target = maskId && targetFor(imageId, maskId); + if (!target) { + messageStore.addError('No segment content to process'); + return undefined; + } + return { + targets: [target], + watchedMaskId: target.maskId, + }; + } + + // 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 }; + } + + function resolveRun(imageId: string, requiresActiveSegment: boolean) { + const refusal = startRefusal(imageId, requiresActiveSegment); + if (refusal) { + messageStore.addError(refusal); + return undefined; + } + return requiresActiveSegment + ? resolveSegmentScoped(imageId) + : resolveEverySegment(imageId); + } + + // 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; label?: string } = {} + ) { + // 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 resolved = resolveRun( + imageId, + options.requiresActiveSegment !== false + ); + if (!resolved) return; + const { targets, watchedMaskId } = resolved; + + const label = options.label ?? 'Process'; + const processRunId = ++activeProcessRunId; + + const written: 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(structuredClone(omit(input, 'voxels'))) + ) + ); + + if (runIsStale(processRunId)) return; + + const runs = targets.flatMap((target, index) => + buildRun(target, 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(outputs, label); + endRun(); + 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); + written.push(run); + }); + + processState.value = { + step: 'previewing', + ...targetedState, + runs, + showingOriginal: false, + }; + } catch (error) { + if (runIsStale(processRunId)) return; + + messageStore.addError(`${label} Operation Failed`, { + error: error as Error, + }); + // Only the masks this run wrote are restored, each still at its run's + // extent. A target it never wrote holds exactly what the user left + // there, and writing a snapshot back would invalidate its renderer for + // nothing. + written.forEach((run) => + writeIfPresent(run.target.voxels, run.originalScalars) + ); + endRun(); + } + } + + /** + * 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) { + 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 segment-scoped run belongs to the segment 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, + startRefusal, + startProcess, + confirmProcess, + cancelProcess, + setShowingOriginal, + }; +}); diff --git a/src/segmentation/editing/processWorker.ts b/src/segmentation/editing/processWorker.ts new file mode 100644 index 000000000..7312f5c4b --- /dev/null +++ b/src/segmentation/editing/processWorker.ts @@ -0,0 +1,94 @@ +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. + */ +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; + pending: Set<(reason: Error) => void>; + discard: (reason: Error) => void; + } | null = null; + + function start() { + const worker = spawn(); + const proxy = Comlink.wrap(worker); + // Per-call rejecters, not a race against one long-lived promise, whose + // reactions would keep every settled result reachable. + const pending = new Set<(reason: Error) => void>(); + 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(); + pending.forEach((reject) => reject(reason)); + pending.clear(); + }; + const die = (event: Event) => discard(workerFailure(event)); + worker.addEventListener('error', die); + worker.addEventListener('messageerror', die); + live = { proxy, pending, discard }; + return live; + } + + function call(use: (api: Comlink.Remote) => T) { + const instance = live ?? start(); + const answer = Promise.resolve().then(() => use(instance.proxy)) as Promise< + Awaited + >; + return new Promise>((resolve, reject) => { + instance.pending.add(reject); + answer + .then(resolve, reject) + .finally(() => instance.pending.delete(reject)); + }); + } + + 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..78dc400ab --- /dev/null +++ b/src/segmentation/editing/rasterizePolygon.ts @@ -0,0 +1,203 @@ +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 { useImageCacheStore } from '@/src/store/image-cache'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import type { Maybe } from '@/src/types'; +import type { LPSAxis } from '@/src/types/lps'; +import { + clipExtent, + emptyExtent, + extentContainsIndex, + extentSize, + fullExtent, + isEmptyExtent, + maskOffset, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { getLPSDirections } from '@/src/utils/lps'; + +export const rasterizeTargetDisabledReason = (segmentId: Maybe) => + useSegmentationStore().editTargetLocked(segmentId) + ? '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 neighbor is a different rule: the fill goes + // around it, which an aimed `voxelClaim` already honors. + 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 { + 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 neighbors it takes it from. + */ +function createGridAccessor( + parent: vtkImageData, + mask: { pixelData: TypedArray; extent: Extent3D }, + plane: { slice: number; axisIdx: 0 | 1 | 2 }, // i/j/k + mayFill: (i: number, j: number, k: number) => boolean +): IGrid2D { + const { slice, axisIdx } = plane; + const { extent, pixelData } = mask; + const axisDims = parent.getDimensions(); + axisDims.splice(axisIdx, 1); + const [mi, mj] = extentSize(extent); + const bounds = { extent, mi, mj }; + // One scratch voxel with the slice already in place: the setter runs per + // filled pixel, so it allocates nothing. + const [axisU, axisV] = [0, 1, 2].filter((axis) => axis !== axisIdx); + const ijk = [0, 0, 0]; + ijk[axisIdx] = slice; + + return { + size: axisDims, + setAtUnsafe(d0: number, d1: number, value: number): boolean { + ijk[axisU] = d0; + ijk[axisV] = d1; + const i = ijk[0]; + const j = ijk[1]; + const k = ijk[2]; + if (!extentContainsIndex(extent, i, j, k) || !mayFill(i, j, k)) + return false; + // XXX assumes single-component image + pixelData[maskOffset(bounds, i, j, k)] = value; + return true; + }, + } 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 +) { + 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 each filled voxel is claimed from the other + * segments under the aimed rule of `voxelClaim`. World points, parent slice + * index. Resolving the edit target 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 neighbor. + // 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; + // 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 neighbor 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, + { pixelData: target.voxels.scalars(), extent }, + { slice, axisIdx: axisIndex }, + (i, j, k) => claimVoxel?.claim(i, j, k) ?? true + ); + + try { + fillPoly(grid, points2D, SEGMENT_VALUE); + } finally { + claimVoxel?.finish(); + mask.modified(); + } + return { segmentId: target.segmentId, maskId: target.maskId }; +} diff --git a/src/segmentation/geometry.ts b/src/segmentation/geometry.ts index 72dd30268..70f82fa25 100644 --- a/src/segmentation/geometry.ts +++ b/src/segmentation/geometry.ts @@ -1,3 +1,5 @@ +import type { TypedArray } from '@kitware/vtk.js/types'; + /** vtk.js index-space extent order: [iMin, iMax, jMin, jMax, kMin, kMax]. */ export type Extent3D = [number, number, number, number, number, number]; @@ -58,6 +60,13 @@ export function extentContainsIndex( ); } +/** Whether the plane at `slice` along `axis` passes through `extent`. */ +export const extentReachesSlice = ( + extent: Extent3D, + axis: number, + slice: number +) => slice >= extent[axis * 2] && slice <= extent[axis * 2 + 1]; + /** A mask's extent with the row and plane strides that extent implies. */ export type MaskBounds = { extent: Extent3D; @@ -78,23 +87,57 @@ export const maskOffset = ( (k - bounds.extent[4]) * bounds.mi * bounds.mj; /** - * The box `labelValue` actually occupies inside a mask bounded by `extent`, - * empty when it occupies nothing. A binding's extent is the allocation, padded - * and never shrunk by an erase, so it is not the segment's bounds. + * Copies a mask onto another extent of its parent grid, padding with zero. + * An output buffer must match the destination size and not alias the source. */ -export function markedExtent( - scalars: ArrayLike, - extent: Extent3D, - labelValue: number +export function reframeMaskScalars( + scalars: TypedArray | number[], + from: Extent3D, + to: Extent3D, + output?: Uint8Array ) { - const ni = extent[1] - extent[0] + 1; - const nj = extent[3] - extent[2] + 1; - const nk = extent[5] - extent[4] + 1; + const [mi, mj, mk] = extentSize(to); + const size = isEmptyExtent(to) ? 0 : mi * mj * mk; + const values = output ?? new Uint8Array(size); + if (values.length !== size) throw new Error('Mask output size mismatch'); + if (output) values.fill(0); + const shared = clipExtent(from, to); + if (isEmptyExtent(shared)) return values; + const [si, sj] = extentSize(from); + // Bounds can be reactive; read them once before the per-row copy loop. + const source = { extent: [...from] as Extent3D, mi: si, mj: sj }; + const destination = { extent: [...to] as Extent3D, mi, mj }; + const count = shared[1] - shared[0] + 1; + for (let k = shared[4]; k <= shared[5]; k += 1) { + for (let j = shared[2]; j <= shared[3]; j += 1) { + const start = maskOffset(source, shared[0], j, k); + const end = maskOffset(destination, shared[0], j, k); + if (count === 1) { + values[end] = scalars[start]; + } else { + const row = Array.isArray(scalars) + ? scalars.slice(start, start + count) + : scalars.subarray(start, start + count); + values.set(row, end); + } + } + } + return values; +} + +/** + * The box the claimed voxels actually occupy inside a mask bounded by + * `extent`, empty when it claims nothing. A binding's extent is the + * allocation, padded and never shrunk by an erase, so it is not the segment's + * bounds. Background is 0, so a claimed voxel is a truthy one. + */ +export function markedExtent(scalars: ArrayLike, extent: Extent3D) { + const [ni, nj, nk] = extentSize(extent); let bounds: Extent3D | undefined; const scanRow = (rowStart: number, j: number, k: number) => { for (let index = 0; index < ni; index += 1) { - if (scalars[rowStart + index] !== labelValue) continue; + if (!scalars[rowStart + index]) continue; const i = extent[0] + index; if (bounds) growExtent(bounds, i, j, k); else bounds = [i, i, j, j, k, k]; @@ -108,18 +151,13 @@ export function markedExtent( return bounds ?? emptyExtent(); } -/** The parent-image slice indices containing `labelValue`, for i, j and k. */ -export function markedSlices( - scalars: ArrayLike, - extent: Extent3D, - labelValue: number -) { - const ni = extent[1] - extent[0] + 1; - const nj = extent[3] - extent[2] + 1; +/** The parent-image slice indices holding a claimed voxel, for i, j and k. */ +export function markedSlices(scalars: ArrayLike, extent: Extent3D) { + const [ni, nj] = extentSize(extent); const occupied = [new Set(), new Set(), new Set()]; for (let offset = 0; offset < scalars.length; offset += 1) { - if (scalars[offset] !== labelValue) continue; + if (!scalars[offset]) continue; const i = extent[0] + (offset % ni); const row = Math.floor(offset / ni); const j = extent[2] + (row % nj); @@ -147,17 +185,27 @@ export function extentUnion(a: Extent3D, b: Extent3D): Extent3D { ]; } -export function padExtent(extent: Extent3D, padding: number): Extent3D { +/** Grows `extent` by `padding` voxels on every side, or by one per axis. */ +export function padExtent( + extent: Extent3D, + padding: number | readonly number[] +): Extent3D { + const [pi, pj, pk] = + typeof padding === 'number' ? [padding, padding, padding] : padding; return [ - extent[0] - padding, - extent[1] + padding, - extent[2] - padding, - extent[3] + padding, - extent[4] - padding, - extent[5] + padding, + extent[0] - pi, + extent[1] + pi, + extent[2] - pj, + extent[3] + pj, + extent[4] - pk, + extent[5] + pk, ]; } +/** `extent` in the index space of a buffer that covers `frame`. */ +export const extentWithin = (extent: Extent3D, frame: Extent3D) => + extent.map((value, index) => value - frame[index - (index % 2)]) as Extent3D; + export function clipExtent(extent: Extent3D, bounds: Extent3D): Extent3D { return [ Math.max(extent[0], bounds[0]), 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..c6600a99e --- /dev/null +++ b/src/segmentation/io/import.ts @@ -0,0 +1,349 @@ +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 an archive-backed image can supply its file-header metadata directly. + */ +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 masks 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 masks 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. Every mask holds SEGMENT_VALUE, 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 +) { + 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 each + // binds its segments against the ones 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 fb74eaecb..e09477700 100644 --- a/src/segmentation/io/maskFileNaming.ts +++ b/src/segmentation/io/maskFileNaming.ts @@ -6,17 +6,16 @@ const defaultName = (baseName: string, index: number) => * rising so a deleted mask's name is not handed straight to the next one, and * `taken` skips a name something already holds. */ -export function createMaskFileNamer(taken: () => Set) { +export function createMaskFileNamer(taken: (name: string) => boolean) { const nextIndex: Record = Object.create(null); return { pick(parentImageId: string, baseName: string) { - const held = taken(); let name = ''; do { const index = nextIndex[parentImageId] ?? 1; nextIndex[parentImageId] = index + 1; name = defaultName(baseName, index); - } while (held.has(name)); + } while (taken(name)); return name; }, /** Called by the deletion cascade, so a removed image stops counting. */ diff --git a/src/segmentation/io/restore.ts b/src/segmentation/io/restore.ts new file mode 100644 index 000000000..3fa6e39a1 --- /dev/null +++ b/src/segmentation/io/restore.ts @@ -0,0 +1,160 @@ +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('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 = { + 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 { segmentations, 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: name || wireBinding.path, 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 } : {}), + }); + }; + + 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] : []; + }); +} + +/** Masks a wire holds but its order leaves out: restore places none of them. */ +export function unlistedWireMasks(wire: { + masks: T[]; + order: string[]; +}) { + const listed = new Set(wire.order); + return wire.masks.filter((mask) => !listed.has(mask.id)); +} diff --git a/src/segmentation/io/stateFile.ts b/src/segmentation/io/stateFile.ts new file mode 100644 index 000000000..6fc826fd0 --- /dev/null +++ b/src/segmentation/io/stateFile.ts @@ -0,0 +1,602 @@ +import type { Ref, ComputedRef } from 'vue'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import vtkLabelMap from '@/src/vtk/LabelMap'; +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import { allocateMask } from '@/src/segmentation/masks/storage'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { + createLoadedImageReader, + orderedWireMasks, + prepareRestoreBindings, + unlistedWireMasks, + 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 writes: Array<() => Promise> = []; + const toWireMask = (parentImageId: string, mask: SegmentMask) => { + const binding = mask.representations.labelmap; + if (!binding) + return { id: mask.id, segmentId: mask.segmentId, representations: {} }; + const labelmap = { + extent: [...binding.extent] as Extent3D, + path: makeMaskArchivePath(binding.name, format, usedArchivePaths), + name: binding.name, + ...(binding.source ? { source: binding.source } : {}), + }; + // Taken with the manifest: a queued write can outlive the segment. + const descriptor = labelmapDescriptorByMask.value[mask.id]; + writes.push(async () => { + // 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); + labelmap.extent = [...binding.extent] as Extent3D; + zip.file(labelmap.path, await io.write(format, image, [descriptor])); + }); + return { + id: mask.id, + segmentId: mask.segmentId, + representations: { labelmap }, + }; + }; + + 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((mask) => + toWireMask(segmentation.parentImageId, mask) + ), + order: [...segmentation.order], + }) + ); + + await mapWithLimit(writes, MASK_IO_CONCURRENCY, (write) => write()); + } + + async function deserialize({ + manifest, + stateFiles, + dataIDMap, + segmentIdMap = {}, + labelmapSources = {}, + io = defaultLabelmapIO, + }: DeserializeOptions) { + const { imports, segmentations: wireSegmentations } = + planLabelmapImports(manifest); + // Planning strips an artifact-bound mask's binding, so its artifact names it. + const artifactLabelOf = new Map( + imports.flatMap((item) => + item.masks.map( + ({ maskId, value }) => + [maskId, `${item.name} label ${value}`] as const + ) + ) + ); + const maskLabel = (wireMask: WireMask) => { + const binding = wireMask.representations.labelmap; + return ( + binding?.name || + binding?.path || + artifactLabelOf.get(wireMask.id) || + wireMask.id + ); + }; + const maskIdMap = new Map(); + // 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 (item.path !== undefined) 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(); + 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 { path } = item; + const key = + path !== undefined + ? `archive:${archivePathKey(path)}` + : `dataset:${storeId}`; + let read = sourceReads.get(key); + if (!read) { + read = (async () => { + if (path !== undefined) { + const file = archiveMember(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, + // whose archive member is missing, or 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 (item.path !== undefined) { + if (archiveMember(item.path)) return true; + skipped.push({ name: item.name, reason: 'archive member is missing' }); + return false; + } + const hasImport = sourceStoreId(item) !== undefined; + if (!hasImport) { + skipped.push({ + name: item.name, + reason: 'labelmap source unavailable', + }); + } + return hasImport; + }); + + // Removed once all reads settle; parent-skipped items may hold a leaf too. + 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 parentId = dataIDMap[item.parentImage]; + const parent = await loadedImage(parentId).catch(() => undefined); + if (!parent) { + skipped.push({ + name: item.name, + reason: imageCacheStore.imageById[parentId] + ? 'parent image did not load' + : 'parent image is unavailable', + }); + return undefined; + } + const labelmap = toLabelMap( + await ensureSameSpace(parent, 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 = wireSegmentations.flatMap(orderedWireMasks); + 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: maskLabel(wireMask), + 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: maskLabel(wireMask), + 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({ + segmentations: wireSegmentations, + 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; + }; + + wireSegmentations.forEach((wire) => { + unlistedWireMasks(wire).forEach((wireMask) => + skipped.push({ + name: maskLabel(wireMask), + reason: 'its segmentation does not list it', + }) + ); + 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) { + // An unnamed one keeps the image's name, as a live segmentation has. + if (wire.name) 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: maskLabel(wireMask), reason }); + return; + } + + const mask = createMask(segmentation.id, segmentId); + + const accepted = acceptedBindings.get(wireMask); + if (accepted) attachMaskBinding(mask.id, accepted); + maskIdMap.set(wireMask.id, mask.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 + ); + segmentRegistry.restoreSelection(restored[activeIndex]?.segmentId); + } else { + const segmentation = getSegmentationForImage(parentImageId); + const targets = item.masks.flatMap(({ maskId, value }) => { + const storeMaskId = maskIdMap.get(maskId); + const mask = storeMaskId && segmentation?.masks[storeMaskId]; + if (!mask) return []; + const segment = segmentRegistry.getSegment(mask.segmentId); + return [{ mask, descriptor: toLabelmapSegment(segment, value) }]; + }); + // Keyed as the minter below hands descriptors back. + 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, skipped }; + } + + return { serialize, deserialize }; +} diff --git a/src/segmentation/masks/overlap.ts b/src/segmentation/masks/overlap.ts index d7cb61db1..2b4b50c07 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, @@ -68,37 +70,38 @@ export function masksHolding(masks: BoundedScalars[], within: Extent3D) { } /** - * Clears the voxel at PARENT indices i, j, k from every one of these masks and - * answers true: nothing left here can refuse the write, which is the same - * per-voxel answer the occupancy test gives. Absent when no mask reaches - * `within`, the box the caller is about to walk. A mask that does not reach the - * voxel has nothing there to clear, so nothing grows. Finish the operation in - * a finally block to publish each changed mask once, including partial writes. + * Clears the voxel at PARENT indices i, j, k from every one of these masks. + * Absent when no mask reaches `within`, the box the caller is about to walk. A + * mask that does not reach the voxel has nothing there to clear, so nothing + * grows. Finish the operation in a finally block to publish each changed mask + * once, including partial writes. */ export function masksClearing(masks: BoundedScalars[], within: Extent3D) { const reaching = masksReaching(masks, within); if (reaching.length === 0) return undefined; - const changed = new Set(); - // Indexed loop, as in masksHolding: claim runs once per voxel the caller + // Flagged by position: a Set would hash a mask per cleared voxel. + const changed = new Uint8Array(reaching.length); + // Indexed loop, as in masksHolding: clear runs once per voxel the caller // walks, and a callback over the reaching masks would allocate per voxel. - const claim = (i: number, j: number, k: number) => { + const clear = (i: number, j: number, k: number) => { for (let index = 0; index < reaching.length; index += 1) { const bounded = reaching[index]; if (extentContainsIndex(bounded.extent, i, j, k)) { const offset = maskOffset(bounded, i, j, k); if (bounded.scalars[offset] !== LABELMAP_BACKGROUND_VALUE) { bounded.scalars[offset] = LABELMAP_BACKGROUND_VALUE; - changed.add(bounded.mask); + changed[index] = 1; } } } - return true; }; return { - claim, + clear, finish: () => { - changed.forEach((mask) => mask.modified()); - changed.clear(); + reaching.forEach((bounded, index) => { + if (changed[index]) bounded.mask.modified(); + }); + changed.fill(0); }, }; } @@ -147,29 +150,117 @@ 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) { + 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; + }); +} + +const maskedBounds = (masks: Array) => + masks.reduce((bounds, bounded) => { + if (!bounded) return bounds; + return bounds ? extentUnion(bounds, bounded.extent) : bounded.extent; + }, undefined); + /** * 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 allocate nothing, as when every mask overlaps every other. */ 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/storage.ts b/src/segmentation/masks/storage.ts index 3e558cba1..cf486d964 100644 --- a/src/segmentation/masks/storage.ts +++ b/src/segmentation/masks/storage.ts @@ -1,13 +1,12 @@ import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import type { TypedArray, Vector3 } from '@kitware/vtk.js/types'; +import type { Vector3 } from '@kitware/vtk.js/types'; import { maskScalars } from '@/src/segmentation/model'; import { - clipExtent, extentSize, isEmptyExtent, - maskOffset, + reframeMaskScalars, type Extent3D, } from '@/src/segmentation/geometry'; import vtkLabelMap from '@/src/vtk/LabelMap'; @@ -48,45 +47,6 @@ export function allocateMask(parent: vtkImageData, extent: Extent3D) { return mask; } -/** - * Copies a mask onto another extent of its parent grid, padding with zero. - * An output buffer must match the destination size and not alias the source. - */ -export function reframeMaskScalars( - scalars: TypedArray | number[], - from: Extent3D, - to: Extent3D, - output?: Uint8Array -) { - const [mi, mj, mk] = extentSize(to); - const size = isEmptyExtent(to) ? 0 : mi * mj * mk; - const values = output ?? new Uint8Array(size); - if (values.length !== size) throw new Error('Mask output size mismatch'); - if (output) values.fill(0); - const shared = clipExtent(from, to); - if (isEmptyExtent(shared)) return values; - const [si, sj] = extentSize(from); - // Bounds can be reactive; read them once before the per-row copy loop. - const source = { extent: [...from] as Extent3D, mi: si, mj: sj }; - const destination = { extent: [...to] as Extent3D, mi, mj }; - const count = shared[1] - shared[0] + 1; - for (let k = shared[4]; k <= shared[5]; k += 1) { - for (let j = shared[2]; j <= shared[3]; j += 1) { - const start = maskOffset(source, shared[0], j, k); - const end = maskOffset(destination, shared[0], j, k); - if (count === 1) { - values[end] = scalars[start]; - } else { - const row = Array.isArray(scalars) - ? scalars.slice(start, start + count) - : scalars.subarray(start, start + count); - values.set(row, end); - } - } - } - return values; -} - /** Preserves the vtk image instance while replacing its scalar storage. */ export function regrowMask( mask: vtkLabelMap, diff --git a/src/segmentation/masks/voxelAccess.ts b/src/segmentation/masks/voxelAccess.ts index a28e603ee..89dfdc867 100644 --- a/src/segmentation/masks/voxelAccess.ts +++ b/src/segmentation/masks/voxelAccess.ts @@ -1,6 +1,6 @@ import type { TypedArray } from '@kitware/vtk.js/types'; -import type { Maybe } from '@/src/types'; +import { partition } from '@/src/utils'; import type { VoxelGesture } from '@/src/segmentation/model'; import type { useImageCacheStore } from '@/src/store/image-cache'; import { regrowMask } from '@/src/segmentation/masks/storage'; @@ -30,11 +30,12 @@ import { type VoxelAccessDeps = { imageCacheStore: ReturnType; - findMask: (maskId: string) => SegmentMask | undefined; + findMaskBinding: (maskId: string) => LabelmapBinding | undefined; getMask: (maskId: string) => SegmentMask; segmentationOfMask: (maskId: string) => Segmentation | undefined; ensureLabelmapBinding: (maskId: string) => LabelmapBinding; maskLocked: (mask: SegmentMask) => boolean; + overlapAllowed: () => boolean; }; /** @@ -45,16 +46,17 @@ type VoxelAccessDeps = { export function createVoxelAccess(deps: VoxelAccessDeps) { const { imageCacheStore, - findMask, + findMaskBinding, getMask, segmentationOfMask, ensureLabelmapBinding, maskLocked, + overlapAllowed, } = deps; function requireParentImage(maskId: string) { const segmentation = segmentationOfMask(maskId); - if (!segmentation) throw new Error('No such segment'); + if (!segmentation) throw new Error('No such mask'); const parent = imageCacheStore.getVtkImageData(segmentation.parentImageId); if (!parent) throw new Error('No such parent image'); return parent; @@ -90,18 +92,19 @@ export function createVoxelAccess(deps: VoxelAccessDeps) { } /** - * The voxel half of the accessor seam, over whichever mask `findBinding` - * resolves. Resolution is deferred to every call so a stale accessor sees - * deletion or growth done through another one. `onMissing` names why storage - * is unreachable, so `exists()` can answer without throwing. + * The voxel half of the accessor seam. Resolution is deferred to every call + * so a stale accessor sees deletion or growth done through another one, and + * a mask deleted out from under it reads as absent storage, not a lookup + * error. `missing` names why storage is unreachable, so `exists()` can answer + * without throwing. */ - function voxelStorage( - maskId: string, - findBinding: () => Maybe, - onMissing: () => never - ): VoxelStorage { - const findImage = () => findBinding()?.image; - const requireImage = () => findImage() ?? onMissing(); + function voxelStorage(maskId: string, missing: string): VoxelStorage { + const findImage = () => findMaskBinding(maskId)?.image; + const requireImage = () => { + const image = findImage(); + if (!image) throw new Error(missing); + return image; + }; const requireScalars = () => maskScalars(requireImage()); return { @@ -130,21 +133,10 @@ export function createVoxelAccess(deps: VoxelAccessDeps) { // front, not just on first use. getMask(maskId); - const binding = () => getMask(maskId).representations.labelmap; - - // Deliberately tolerant where binding() is not: the segment itself can be - // deleted out from under an accessor, and that is an absent storage, not a - // lookup error. - const findBinding = () => findMask(maskId)?.representations.labelmap; - - const onMissing = (): never => { - throw new Error('No storage: call materialize() first'); - }; - return { - binding, + binding: () => getMask(maskId).representations.labelmap, materialize: () => ensureLabelmapBinding(maskId), - ...voxelStorage(maskId, findBinding, onMissing), + ...voxelStorage(maskId, 'No storage: call materialize() first'), }; } @@ -154,54 +146,59 @@ export function createVoxelAccess(deps: VoxelAccessDeps) { * vanish a tick before they do, so this stays constructible either way. */ const findMaskVoxels = (maskId: string) => - voxelStorage( - maskId, - () => findMask(maskId)?.representations.labelmap, - () => { - throw new Error('No such segment'); - } - ); + voxelStorage(maskId, 'No such mask'); /** A bound segment's buffer, absent when it has none or holds nothing. */ const boundedMask = (binding?: LabelmapBinding) => 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((mask) => mask.id !== maskId) + : []; + const [takeFrom, yieldTo] = partition( + (mask) => gesture === 'aimed' && !maskLocked(mask), + others + ); + const bounded = (masks: SegmentMask[]) => + masks.flatMap((mask) => boundedMask(mask.representations.labelmap) ?? []); + return { takeFrom: bounded(takeFrom), yieldTo: bounded(yieldTo) }; } /** * 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 neighbors that have to give it up. Asked before the + * write: a voxel a neighbor 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) => { + if (held?.(i, j, k)) return false; + clearing?.clear(i, j, k); + return true; + }, + finish: () => clearing?.finish(), + }; } return { diff --git a/src/segmentation/model.ts b/src/segmentation/model.ts index 4d8461dbb..4d069c952 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'; @@ -7,6 +7,12 @@ import type vtkLabelMap from '@/src/vtk/LabelMap'; /** A fresh segmentation tints the anatomy under it rather than hiding it. */ export const DEFAULT_SEGMENTATION_FILL_OPACITY = 0.3; +export const DEFAULT_SEGMENTATION_DISPLAY = { + fillOpacity: DEFAULT_SEGMENTATION_FILL_OPACITY, + outlineOpacity: 1, + outlineThickness: 2, +}; + export type LabelmapBinding = { /** * This mask's voxels, and no other segment's. Held raw: a vtk object must @@ -20,9 +26,9 @@ export type LabelmapBinding = { }; /** - * One image's mask for one segment type. Its id is its own, distinct from the - * type id: everything the user sees or sets, visibility and lock included, - * lives on the type, so this record is storage and nothing else. + * One image's mask for one segment. Its id is its own, distinct from the + * segment id: everything the user sees or sets, visibility and lock included, + * lives on the segment, so this record is storage and nothing else. */ export type SegmentMask = { id: string; @@ -33,6 +39,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}`; @@ -53,6 +70,9 @@ export type LabelmapSegment = { outlineOpacity?: number; }; +/** A descriptor projected off a segment, every appearance field resolved. */ +export type ResolvedLabelmapSegment = Required; + /** vtk declares getData() as number[] | TypedArray; mask storage is typed. */ export const maskScalars = (mask: vtkLabelMap) => mask.getPointData().getScalars().getData() as Uint8Array; @@ -123,5 +143,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..030956575 --- /dev/null +++ b/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue @@ -0,0 +1,197 @@ + + + diff --git a/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts b/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts index bfb018c2a..919194f99 100644 --- a/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts +++ b/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts @@ -2,115 +2,83 @@ import { describe, expect, it } from 'vitest'; import { SEGMENT_ACTOR_OPACITY, + SEGMENT_COINCIDENT_OFFSET, segmentFillAlpha, - segmentOutlineTables, + segmentOutline, } from '@/src/segmentation/rendering/display'; -import type { LabelmapSegment } from '@/src/segmentation/model'; - -const makeMask = ( - value: number, - overrides: Partial = {} -): LabelmapSegment => ({ - value, - name: `Segment ${value}`, +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import type { ResolvedLabelmapSegment } from '@/src/segmentation/model'; + +const segment = ( + overrides: Partial = {} +): ResolvedLabelmapSegment => ({ + value: SEGMENT_VALUE, + name: 'Segment', color: [255, 0, 0, 255], visible: true, + locked: false, + fillOpacity: 1, + outlineOpacity: 1, ...overrides, }); describe('segmentFillAlpha', () => { it('is the segment alpha when the fill is fully opaque', () => { - expect(segmentFillAlpha(makeMask(1, { fillOpacity: 1 }))).toBe(1); + expect( + segmentFillAlpha(segment({ color: [255, 0, 0, 51] }), 1) + ).toBeCloseTo(0.2); }); it('scales the segment alpha by the fill opacity', () => { - expect(segmentFillAlpha(makeMask(1, { fillOpacity: 0.5 }))).toBe(0.5); + expect( + segmentFillAlpha(segment({ color: [255, 0, 0, 51], fillOpacity: 0.5 }), 1) + ).toBeCloseTo(0.1); + }); + + it('hides a segment with zero color alpha', () => { + expect(segmentFillAlpha(segment({ color: [255, 0, 0, 0] }), 1)).toBe(0); }); it('hides a fill the user set to zero', () => { - expect(segmentFillAlpha(makeMask(1, { fillOpacity: 0 }))).toBe(0); + expect(segmentFillAlpha(segment({ fillOpacity: 0 }), 1)).toBe(0); }); it('hides an invisible segment whatever its fill opacity', () => { expect( - segmentFillAlpha(makeMask(1, { visible: false, fillOpacity: 1 })) + segmentFillAlpha(segment({ visible: false, fillOpacity: 1 }), 1) ).toBe(0); }); - it('treats a descriptor without a fill opacity as opaque', () => { - expect(segmentFillAlpha(makeMask(1))).toBe(1); + it("scales the segment alpha by the segmentation's fill opacity", () => { + expect(segmentFillAlpha(segment({ fillOpacity: 0.5 }), 0.5)).toBe(0.25); }); - it('scales the segment alpha by the segmentation\u2019s fill opacity', () => { - expect(segmentFillAlpha(makeMask(1, { fillOpacity: 0.5 }), 0.5)).toBe(0.25); - }); - - it('hides every fill when the segmentation\u2019s fill opacity is zero', () => { - expect(segmentFillAlpha(makeMask(1, { fillOpacity: 1 }), 0)).toBe(0); + it("hides every fill when the segmentation's fill opacity is zero", () => { + expect(segmentFillAlpha(segment({ fillOpacity: 1 }), 0)).toBe(0); }); }); -describe('segmentOutlineTables', () => { - it('indexes both tables by label value minus one', () => { - const tables = segmentOutlineTables( - [ - makeMask(1, { outlineOpacity: 0.25 }), - makeMask(2, { outlineOpacity: 0.5 }), - ], - 2, - 1 - ); - - expect(tables.opacities).toEqual([0.25, 0.5]); - expect(tables.thicknesses).toEqual([2, 2]); - }); - +describe('segmentOutline', () => { it('hides an outline the user set to zero', () => { - const tables = segmentOutlineTables( - [makeMask(1, { outlineOpacity: 0 })], - 2, - 1 - ); + const tables = segmentOutline(segment({ outlineOpacity: 0 }), 2, 1); expect(tables.opacities).toEqual([0]); }); - it('scales every segment by the group outline opacity', () => { - const tables = segmentOutlineTables( - [makeMask(1, { outlineOpacity: 0.5 })], - 2, - 0.5 - ); + it('scales the segment by the segmentation outline opacity', () => { + const tables = segmentOutline(segment({ outlineOpacity: 0.5 }), 2, 0.5); expect(tables.opacities).toEqual([0.25]); }); - it('leaves values no segment claims at the group defaults', () => { - const tables = segmentOutlineTables( - [makeMask(3, { outlineOpacity: 0.5 })], - 2, - 1 - ); - - expect(tables.opacities).toEqual([1, 1, 0.5]); - expect(tables.thicknesses).toEqual([2, 2, 2]); + it('keeps the thickness of a visible segment', () => { + expect(segmentOutline(segment(), 2, 1).thicknesses).toEqual([2]); }); it('drops the thickness of an invisible segment', () => { - const tables = segmentOutlineTables( - [makeMask(1, { visible: false }), makeMask(2)], - 2, - 1 - ); - - expect(tables.thicknesses).toEqual([0, 2]); - }); + const tables = segmentOutline(segment({ visible: false }), 2, 1); - it('has no entries for an artifact with no bound segments', () => { - expect(segmentOutlineTables([], 2, 1)).toEqual({ - thicknesses: [], - opacities: [], - }); + expect(tables.thicknesses).toEqual([0]); }); }); @@ -127,3 +95,11 @@ describe('SEGMENT_ACTOR_OPACITY', () => { expect(SEGMENT_ACTOR_OPACITY).toBeLessThan(1); }); }); + +describe('SEGMENT_COINCIDENT_OFFSET', () => { + it('uses negative offsets to put segments in front of the base image', () => { + const [factor, units] = SEGMENT_COINCIDENT_OFFSET; + expect(factor).toBeLessThan(0); + expect(units).toBeLessThan(0); + }); +}); diff --git a/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts b/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts index efb2930fb..e58d7590c 100644 --- a/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts +++ b/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts @@ -3,7 +3,10 @@ import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import { allocateMask, regrowMask } 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 { + segmentRenderMask, + type RenderMaskSlot, +} from '@/src/segmentation/rendering/renderMask'; function scene(extent: Extent3D) { const parent = vtkImageData.newInstance({ @@ -77,23 +80,26 @@ describe('render-only segment slices', () => { it('refreshes after edits, slice changes and growth', () => { const extent: Extent3D = [6, 6, 7, 7, 8, 9]; const { parent, source } = scene(extent); + const slot: RenderMaskSlot = {}; const first = segmentRenderMask(source, parent, extent, { axis: 2, index: 8, + slot, })!; expect( - segmentRenderMask(source, parent, extent, { axis: 2, index: 8 }) + segmentRenderMask(source, parent, extent, { axis: 2, index: 8, slot }) ).toBe(first); maskScalars(source)[0] = 0; source.modified(); expect( - segmentRenderMask(source, parent, extent, { axis: 2, index: 8 }) + segmentRenderMask(source, parent, extent, { axis: 2, index: 8, slot }) ).toBe(first); expect([...maskScalars(first)].every((v) => v === 0)).toBe(true); expect(first.getPointData().getScalars().getRange()).toEqual([0, 0]); const nextSlice = segmentRenderMask(source, parent, extent, { axis: 2, index: 9, + slot, })!; expect([...maskScalars(nextSlice)]).toEqual([0, 0, 0, 0, 1, 0, 0, 0, 0]); expect( @@ -106,9 +112,38 @@ describe('render-only segment slices', () => { const next = segmentRenderMask(source, parent, grown, { axis: 2, index: 8, + slot, })!; expect(next.getDimensions()).toEqual([4, 3, 1]); expect(next.getPointData().getScalars().getRange()).toEqual([0, 1]); expect([...maskScalars(next)].reduce((a, b) => a + b, 0)).toBe(1); }); + + it('keeps each slot on its own slice of the same axis', () => { + const extent: Extent3D = [6, 6, 7, 7, 8, 9]; + const { parent, source } = scene(extent); + const upper: RenderMaskSlot = {}; + const lower: RenderMaskSlot = {}; + const onUpper = () => + segmentRenderMask(source, parent, extent, { + axis: 2, + index: 8, + slot: upper, + }); + const onLower = () => + segmentRenderMask(source, parent, extent, { + axis: 2, + index: 9, + slot: lower, + }); + const first = { upper: onUpper(), lower: onLower() }; + + maskScalars(source)[1] = 0; + source.modified(); + + expect(onUpper()).toBe(first.upper); + expect(onLower()).toBe(first.lower); + expect([...maskScalars(first.upper!)].reduce((a, b) => a + b, 0)).toBe(1); + expect([...maskScalars(first.lower!)].every((v) => v === 0)).toBe(true); + }); }); diff --git a/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts b/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts deleted file mode 100644 index d9d3d39fe..000000000 --- a/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - segmentCoincidentOffset, - sliceWithinExtent, -} from '@/src/segmentation/rendering/display'; -import { emptyExtent, type Extent3D } from '@/src/segmentation/geometry'; - -// --------------------------------------------------------------------------- -// The two 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 -// vtkImageMapper clamps a slice outside its input to the nearest one, so an -// actor left visible off its own extent paints a stale slice over the image. -// 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. -// --------------------------------------------------------------------------- - -const EXTENT: Extent3D = [1, 2, 0, 3, 2, 5]; - -describe('sliceWithinExtent', () => { - it('is true for a slice inside the extent', () => { - expect(sliceWithinExtent(EXTENT, 2, 3)).toBe(true); - }); - - it('includes both ends of the extent', () => { - expect(sliceWithinExtent(EXTENT, 2, 2)).toBe(true); - expect(sliceWithinExtent(EXTENT, 2, 5)).toBe(true); - }); - - it('is false below the extent', () => { - expect(sliceWithinExtent(EXTENT, 2, 1)).toBe(false); - }); - - it('is false above the extent', () => { - expect(sliceWithinExtent(EXTENT, 2, 6)).toBe(false); - }); - - it('reads the axis it is given', () => { - expect(sliceWithinExtent(EXTENT, 0, 3)).toBe(false); - expect(sliceWithinExtent(EXTENT, 1, 3)).toBe(true); - }); - - it('is false for a mask that covers nothing, on every axis', () => { - expect(sliceWithinExtent(emptyExtent(), 0, 0)).toBe(false); - expect(sliceWithinExtent(emptyExtent(), 1, 0)).toBe(false); - expect(sliceWithinExtent(emptyExtent(), 2, 0)).toBe(false); - }); -}); - -describe('segmentCoincidentOffset', () => { - it('puts the first segment in front of the base image', () => { - const [factor, units] = segmentCoincidentOffset(0); - - expect(factor).toBeLessThan(0); - expect(units).toBeLessThan(0); - }); - - it('puts a greater stack index in front of a smaller one', () => { - const [earlierFactor, earlierUnits] = segmentCoincidentOffset(0); - const [laterFactor, laterUnits] = segmentCoincidentOffset(1); - - expect(laterUnits).toBeLessThan(earlierUnits); - expect(laterFactor).toBeLessThanOrEqual(earlierFactor); - }); - - 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]); - }); - }); -}); diff --git a/src/segmentation/rendering/display.ts b/src/segmentation/rendering/display.ts index 484cae0e3..b51130269 100644 --- a/src/segmentation/rendering/display.ts +++ b/src/segmentation/rendering/display.ts @@ -1,24 +1,4 @@ -import type { LabelmapSegment } from '@/src/segmentation/model'; -import type { Extent3D } from '@/src/segmentation/geometry'; -import { isEmptyExtent } from '@/src/segmentation/geometry'; - -/** - * Whether a segment's actor has anything to draw on the slice being viewed. - * Extent and slice are both in the parent image's index space, on the index - * axis the view's LPS axis maps to. vtkImageMapper clamps a slice outside its - * input to the nearest one, so an actor left visible off its own extent paints - * a stale slice over the image. - */ -export function sliceWithinExtent( - extent: Extent3D, - axisIndex: number, - slice: number -) { - if (isEmptyExtent(extent)) return false; - return slice >= extent[axisIndex * 2] && slice <= extent[axisIndex * 2 + 1]; -} - -const SEGMENT_OFFSET_FACTOR = -4; +import type { ResolvedLabelmapSegment } from '@/src/segmentation/model'; /** * Actor opacity for a segment's slice representation. Per-segment and @@ -30,58 +10,38 @@ 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 = [-4, -4] as const; /** * Fill alpha in 0..1 for the slice representation's piecewise function: the * segment's own alpha scaled by its fill opacity and by the segmentation's, - * the same way the outline tables compose theirs. + * the same way segmentOutline composes the outline opacity. */ export const segmentFillAlpha = ( - segment: LabelmapSegment, - segmentationOpacity = 1 + segment: ResolvedLabelmapSegment, + segmentationOpacity: number ) => segment.visible ? ((segment.color[3] || 0) / 255) * - (segment.fillOpacity ?? 1) * + segment.fillOpacity * segmentationOpacity : 0; /** - * The label outline tables vtk.js indexes by label value minus one, so both run - * from value 1 to the largest value in use. A value no segment claims keeps the - * segmentation defaults. + * The label outline tables vtk.js indexes by label value minus one, for the one + * label a mask holds. */ -export const segmentOutlineTables = ( - segments: LabelmapSegment[], - segmentationThickness: number, +export const segmentOutline = ( + segment: ResolvedLabelmapSegment, + thickness: number, segmentationOpacity: number -) => { - const byValue = new Map(segments.map((segment) => [segment.value, segment])); - const largestValue = segments.reduce( - (largest, segment) => Math.max(largest, segment.value), - 0 - ); - const at = (index: number) => byValue.get(index + 1); - - return { - thicknesses: Array.from({ length: largestValue }, (_, index) => { - const segment = at(index); - return !segment || segment.visible ? segmentationThickness : 0; - }), - opacities: Array.from( - { length: largestValue }, - (_, index) => segmentationOpacity * (at(index)?.outlineOpacity ?? 1) - ), - }; -}; +) => ({ + thicknesses: [segment.visible ? thickness : 0], + opacities: [segmentationOpacity * segment.outlineOpacity], +}); diff --git a/src/segmentation/rendering/projection.ts b/src/segmentation/rendering/projection.ts index 04029ada7..79df10503 100644 --- a/src/segmentation/rendering/projection.ts +++ b/src/segmentation/rendering/projection.ts @@ -8,7 +8,7 @@ import { import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; import { listMasks, - type LabelmapSegment, + type ResolvedLabelmapSegment, type Segmentation, } from '@/src/segmentation/model'; @@ -22,7 +22,7 @@ export function createSegmentProjection({ segmentations, segmentRegistry, }: SegmentProjectionDeps) { - return computed((previous: Record = {}) => { + return computed((previous: Record = {}) => { const stable = Object.fromEntries( Object.values(segmentations) .flatMap(listMasks) diff --git a/src/segmentation/rendering/renderMask.ts b/src/segmentation/rendering/renderMask.ts index 78a2343c4..5d87b4dfc 100644 --- a/src/segmentation/rendering/renderMask.ts +++ b/src/segmentation/rendering/renderMask.ts @@ -1,47 +1,60 @@ import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import type vtkLabelMap from '@/src/vtk/LabelMap'; -import { - allocateMask, - reframeMaskScalars, -} from '@/src/segmentation/masks/storage'; +import { allocateMask } from '@/src/segmentation/masks/storage'; import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; import { maskScalars } from '@/src/segmentation/model'; import { clipExtent, + extentReachesSlice, isEmptyExtent, padExtent, + reframeMaskScalars, type Extent3D, } from '@/src/segmentation/geometry'; -// Keep only the current slice per axis, outside segmentation storage and export. -const renderMasks = new WeakMap< - vtkLabelMap, - Map< - number, - { - image: vtkLabelMap; - key: string; - mtime: number; - } - > ->(); +type RenderSlice = { + source: vtkLabelMap; + image: vtkLabelMap; + key: string; + mtime: number; +}; -/** Add known background within the scan, without inventing data beyond it. */ -export function segmentRenderMask( - source: vtkLabelMap, - parent: vtkImageData, +// Held by one representation, so views on different slices of one axis do not +// evict each other's slice. Kept outside segmentation storage and export. +export type RenderMaskSlot = { slice?: RenderSlice }; + +// The displayed plane padded with known background, or null off the mask. +function renderExtent( extent: Extent3D, - { axis, index: slice }: { axis: number; index: number } + parent: vtkImageData, + axis: number, + slice: number ) { if (isEmptyExtent(extent)) return null; + const index = Math.round(slice); + if (!extentReachesSlice(extent, axis, index)) return null; const padded = clipExtent( padExtent(extent, 1), parent.getExtent() as Extent3D ); - const index = Math.round(slice); - if (index < extent[axis * 2] || index > extent[axis * 2 + 1]) return null; padded[axis * 2] = index; padded[axis * 2 + 1] = index; + return padded; +} + +/** Add known background within the scan, without inventing data beyond it. */ +export function segmentRenderMask( + source: vtkLabelMap, + parent: vtkImageData, + extent: Extent3D, + { + axis, + index, + slot = {}, + }: { axis: number; index: number; slot?: RenderMaskSlot } +) { + const padded = renderExtent(extent, parent, axis, index); + if (!padded) return null; const key = [ ...extent, ...padded, @@ -49,15 +62,10 @@ export function segmentRenderMask( ...parent.getSpacing(), ...parent.getDirection(), ].join(','); - let slices = renderMasks.get(source); - if (!slices) { - slices = new Map(); - renderMasks.set(source, slices); - } - let cached = slices.get(axis); - if (!cached || cached.key !== key) { - cached = { image: allocateMask(parent, padded), key, mtime: -1 }; - slices.set(axis, cached); + let cached = slot.slice; + if (!cached || cached.source !== source || cached.key !== key) { + cached = { source, image: allocateMask(parent, padded), key, mtime: -1 }; + slot.slice = cached; } if (cached.mtime !== source.getMTime()) { const values = reframeMaskScalars( diff --git a/src/segmentation/rendering/revealPulse.ts b/src/segmentation/rendering/revealPulse.ts new file mode 100644 index 000000000..a0f3df905 --- /dev/null +++ b/src/segmentation/rendering/revealPulse.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/segment.ts b/src/segmentation/segment.ts index 47518eb1e..f1ff545ab 100644 --- a/src/segmentation/segment.ts +++ b/src/segmentation/segment.ts @@ -13,8 +13,8 @@ import { cssColorToRGBA, rgbaToCssColor } from '@/src/segmentation/color'; * Identity and shared appearance for everything drawn as one thing: a paint * mask on any image, a rectangle, a polygon. Appearance fields are absent * until set and mean "app default" while they are, so a configured or imported - * type that states nothing follows the default and a ruler type carries no - * meaningless opacity. + * segment that states nothing follows the default and a ruler's segment carries + * no meaningless opacity. */ export type Segment = { id: string; @@ -43,12 +43,12 @@ const APPEARANCE_DEFAULTS = { }; /** - * The one resolver. Every renderer, editor and encoder reads a type through - * it; nothing reads the optional fields directly, so an absent field means the - * app default in exactly one place. + * The one resolver. Every renderer, editor and encoder reads a segment + * through it; nothing reads the optional fields directly, so an absent field + * means the app default in exactly one place. */ -export const resolveSegmentAppearance = (type: Maybe) => { - const stated: Partial = type ?? {}; +export const resolveSegmentAppearance = (segment: Maybe) => { + const stated: Partial = segment ?? {}; const resolved = { ...APPEARANCE_DEFAULTS, ...cleanUndefined({ @@ -70,10 +70,10 @@ export const resolveSegmentAppearance = (type: Maybe) => { * consume it. */ export const toLabelmapSegment = ( - type: Maybe, + segment: Maybe, labelValue: number -): LabelmapSegment => { - const resolved = resolveSegmentAppearance(type); +) => { + const resolved = resolveSegmentAppearance(segment); return { value: labelValue, name: resolved.name, diff --git a/src/segmentation/segmentReferences.ts b/src/segmentation/segmentReferences.ts deleted file mode 100644 index 62541be05..000000000 --- a/src/segmentation/segmentReferences.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getActivePinia, type Pinia } from 'pinia'; - -// Who points at a segment, declared by the stores that hold references. -// -// The registry knows nothing about masks or shapes: it asks these declarations -// whether a segment is still referenced and hands them the removal. Declarations -// are registered from store setup and scoped to the application instance that -// ran it, so two applications, or two tests, never answer for each other. -// Dependency-free on purpose: a store import here would close a cycle back -// through the registry. - -export type SegmentReferenceHolder = { - has: (segmentId: string) => boolean; - remove: (segmentId: string) => void; -}; - -const holdersByApp = new WeakMap>(); - -const holdersOf = () => { - const pinia = getActivePinia(); - if (!pinia) return undefined; - const existing = holdersByApp.get(pinia); - if (existing) return existing; - const holders = new Map(); - holdersByApp.set(pinia, holders); - return holders; -}; - -export function declareSegmentReferences( - name: string, - holder: SegmentReferenceHolder -) { - holdersOf()?.set(name, holder); -} - -export const segmentIsReferenced = (segmentId: string) => - [...(holdersOf()?.values() ?? [])].some((holder) => holder.has(segmentId)); - -export const removeSegmentReferences = (segmentId: string) => - holdersOf()?.forEach((holder) => holder.remove(segmentId)); diff --git a/src/segmentation/segmentRegistry.ts b/src/segmentation/segmentRegistry.ts index 30eeb84ab..9608f1bca 100644 --- a/src/segmentation/segmentRegistry.ts +++ b/src/segmentation/segmentRegistry.ts @@ -3,36 +3,25 @@ import { computed, ref, type Ref } from 'vue'; import { TOOL_COLORS } from '@/src/config'; import { useIdStore } from '@/src/store/id'; import type { Maybe } from '@/src/types'; -import { cssColorToRGBA } from '@/src/segmentation/color'; +import { cssColorToRGBA, tryCssColorToRGBA } from '@/src/segmentation/color'; import { resolveSegmentAppearance, type Segment, type SegmentInit, } from '@/src/segmentation/segment'; -import { omit } from '@/src/utils'; -import { cleanUndefined } from '@/src/utils'; - -type ConfiguredSegment = { - color?: string; - fillOpacity?: number; - outlineOpacity?: number; - strokeWidth?: number; -}; - -type ConfiguredSegments = Record; +import { cleanUndefined, cycle } from '@/src/utils'; +import type { ConfiguredSegments } from '@/src/io/import/configSegments'; -export type SegmentRegistryOptions = { - hasReferences?: (segmentId: string) => boolean; - removeReferences?: (segmentId: string) => void; +type SegmentReferenceHolder = { + has: (segmentId: string) => boolean; + remove: (segmentId: string) => void; }; -const fromConfigured = ( - name: string, - configured: ConfiguredSegment -): SegmentInit => +// An unparseable color is left out, so the segment keeps the color it has. +const fromConfigured = (name: string, configured: ConfiguredSegments[string]) => cleanUndefined({ name, - color: configured.color ? cssColorToRGBA(configured.color) : undefined, + color: configured.color ? tryCssColorToRGBA(configured.color) : undefined, fillOpacity: configured.fillOpacity, outlineOpacity: configured.outlineOpacity, strokeWidth: configured.strokeWidth, @@ -50,10 +39,17 @@ const configuredAppearance = ({ * paint, rectangles, polygons and rulers together. Explicit order drives the * picker, shortcuts, serialization and labelmap stacking. */ -export const createSegmentRegistry = ({ - hasReferences = () => false, - removeReferences = () => {}, -}: SegmentRegistryOptions = {}) => { +export const createSegmentRegistry = () => { + // Stores holding masks or shapes declare them here; the registry knows neither. + const referenceHolders = new Map(); + const declareReferences = (name: string, holder: SegmentReferenceHolder) => { + referenceHolders.set(name, holder); + }; + const hasReferences = (segmentId: string) => + [...referenceHolders.values()].some((holder) => holder.has(segmentId)); + const removeReferences = (segmentId: string) => + referenceHolders.forEach((holder) => holder.remove(segmentId)); + const segmentById = ref>({}) as Ref< Record >; @@ -63,25 +59,58 @@ export const createSegmentRegistry = ({ segmentOrder.value.map((id) => segmentById.value[id]) ); - const selectedSegmentId = ref>(); + /** + * 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 getSegment = (id: Maybe) => + id ? segmentById.value[id] : undefined; + + const chosenSegmentId = ref>(); const selectionRevision = ref(0); - // A type that is gone is not selected. - const selectedSegment = computed(() => - selectedSegmentId.value - ? segmentById.value[selectedSegmentId.value] - : undefined + // Whenever segments exist one is selected, so every edit lands where the + // highlighted row says: the user's choice while it exists, else the first. + const selectedSegmentId = computed( + () => (getSegment(chosenSegmentId.value) ?? segmentList.value[0])?.id ); + // Only a live segment can be chosen, so nothing clears a choice back to none. const selectSegment = (id: Maybe) => { - selectedSegmentId.value = id && segmentById.value[id] ? id : undefined; + if (!getSegment(id)) return; + chosenSegmentId.value = id; // Reselecting the same segment can still request that its row be revealed. - if (selectedSegmentId.value) selectionRevision.value += 1; + selectionRevision.value += 1; }; - const getSegment = (id: Maybe) => - id ? segmentById.value[id] : undefined; - const appearanceOf = (id: Maybe) => resolveSegmentAppearance(getSegment(id)); @@ -91,50 +120,53 @@ export const createSegmentRegistry = ({ () => new Map(segmentOrder.value.map((id, index) => [id, index])) ); - const orderIndexOf = (id: Maybe) => - (id === undefined || id === null ? undefined : orderIndex.value.get(id)) ?? - -1; - - const findSegmentByName = (name: Maybe) => - segmentList.value.find((type) => type.name === name); + const orderIndexOf = (id: string) => orderIndex.value.get(id) ?? -1; - 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 findSegmentByName = (name: string) => { + // The index keys on the trimmed name; the answer is still an exact match. + const matches = (idsByName.get(name.trim()) ?? []).filter( + (id) => segmentById.value[id]?.name === name + ); + // Several segments carry the name: the first in registry order answers. + const [first] = matches.sort((a, b) => orderIndexOf(a) - orderIndexOf(b)); + return getSegment(first); }; - 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}`; + 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}`; }; - let nextColorIndex = 0; - const nextColor = () => { - const color = cssColorToRGBA(TOOL_COLORS[nextColorIndex]); - nextColorIndex = (nextColorIndex + 1) % TOOL_COLORS.length; - return color; + // 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; }; - /** Mints a segment without touching the selection. Allocates no voxels. */ + const defaultName = () => lowestFreeName('Segment ', '', 1); + + const nextToolColor = cycle(TOOL_COLORS); + const nextColor = () => cssColorToRGBA(nextToolColor()); + + /** Mints a segment without choosing it. 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: a copy per mint makes a large import quadratic. + 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; }; @@ -145,24 +177,34 @@ 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 segment = segmentById.value[id]; + if (!segment) return; + const next = { + ...segment, + ...patch, + // Undefined resets an optional appearance field, not a required one. + name: patch.name ?? segment.name, + color: patch.color ?? segment.color, + visible: patch.visible ?? segment.visible, + locked: patch.locked ?? segment.locked, + id, }; + if (next.name !== segment.name) { + unindexName(segment.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 segment = segmentById.value[id]; + if (!segment) return; removeReferences(id); segmentOrder.value = segmentOrder.value.filter((key) => key !== id); - segmentById.value = omit(segmentById.value, id); - if (selectedSegmentId.value === id) { - selectSegment(segmentOrder.value[0]); - } + unindexName(segment.name, id); + delete segmentById.value[id]; }; const moveSegment = (id: string, target: string, after = false) => { @@ -172,13 +214,7 @@ export const createSegmentRegistry = ({ segmentOrder.value = order; }; - const ensureSelectedSegment = () => { - if (selectedSegment.value) return selectedSegment.value.id; - const first = segmentList.value[0]; - if (!first) return addSegment(); - selectSegment(first.id); - return first.id; - }; + const ensureSelectedSegment = () => selectedSegmentId.value ?? addSegment(); /** * Exact-name lookup, minting on a miss. Import binds descriptors this way, @@ -198,7 +234,11 @@ export const createSegmentRegistry = ({ // a restored segment begins with its session appearance. const configEntries = new Map< string, - { id: string; appearance: ReturnType } + { + id: string; + appearance: ReturnType; + minted: boolean; + } >(); const replaceConfigSegments = (configured: Maybe) => { @@ -207,8 +247,13 @@ export const createSegmentRegistry = ({ Object.entries(next).forEach(([name, props]) => { let entry = configEntries.get(name); if (!entry || !getSegment(entry.id)) { - const id = findSegmentByName(name)?.id ?? mintSegment({ name }); - entry = { id, appearance: configuredAppearance(getSegment(id)!) }; + const matched = findSegmentByName(name)?.id; + const id = matched ?? mintSegment({ name }); + entry = { + id, + appearance: configuredAppearance(getSegment(id)!), + minted: !matched, + }; } updateSegment(entry.id, { ...entry.appearance, @@ -218,43 +263,52 @@ export const createSegmentRegistry = ({ }); [...configEntries.entries()] - .filter(([name]) => !(name in next)) - .forEach(([name, { id }]) => { + .filter(([name]) => !Object.hasOwn(next, name)) + .forEach(([name, { id, minted }]) => { configEntries.delete(name); - // Content keeps the last configured appearance as session state. - if (segmentById.value[id] && !hasReferences(id)) deleteSegment(id); + // Content keeps its last configured appearance; a segment the config + // matched by name rather than minted is not the config's to drop. + if (minted && segmentById.value[id] && !hasReferences(id)) + deleteSegment(id); }); - - // A configured registry offers a selection from the start; selecting - // creates nothing, so the first edit lands in a configured type rather - // than minting one beside it. - if (!selectedSegment.value) selectSegment(segmentList.value[0]?.id); }; // --- wire --- // - const serialize = () => segmentList.value.map((type) => ({ ...type })); + const serialize = () => segmentList.value.map((segment) => ({ ...segment })); /** * Seats restored segments beside the ones already here. Ids are minted fresh * 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; + }; + + /** Selects a restored segment unless the user already chose one. */ + const restoreSelection = (id: Maybe) => { + if (!getSegment(chosenSegmentId.value)) selectSegment(id); + }; return { - segmentById, segmentList, selectedSegmentId, selectionRevision, - selectedSegment, selectSegment, + restoreSelection, getSegment, appearanceOf, orderIndexOf, @@ -270,6 +324,7 @@ export const createSegmentRegistry = ({ replaceConfigSegments, serialize, adopt, + declareReferences, }; }; diff --git a/src/segmentation/segments.ts b/src/segmentation/segments.ts new file mode 100644 index 000000000..c09ea7f91 --- /dev/null +++ b/src/segmentation/segments.ts @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia'; +import { markRaw } from 'vue'; + +import type { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { createSegmentRegistry } from '@/src/segmentation/segmentRegistry'; + +/** + * 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(); + + 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); + registry.restoreSelection( + manifest.selectedSegment && segmentIdMap[manifest.selectedSegment] + ); + 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..734ab41d2 --- /dev/null +++ b/src/segmentation/store.ts @@ -0,0 +1,664 @@ +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 { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +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_DISPLAY, + 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 { useMessageStore } from '@/src/store/messages'; +import { + cleanUndefined, + cycle, + ensureError, + isRecord, + removeFromArray, +} from '@/src/utils'; +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 conversions running for each child image, by parent, so a second + // caller for the 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< + DataSelection, + 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((name) => + maskNameHolders.has(name) + ); + + /** + * 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; + } + + // Mask ids are globally unique and one segmentation per image is enforced, + // so a mask id alone finds its segmentation. + 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 mask'); + return segmentation; + } + + function getMask(maskId: string) { + const mask = findMask(maskId); + if (!mask) throw new Error('No such mask'); + return mask; + } + + 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: [], + ...DEFAULT_SEGMENTATION_DISPLAY, + }; + 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'); + 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); + } + + // `locked` is required, so a per-stroke read skips resolving the appearance. + const segmentLocked = (segmentId: Maybe) => + segmentRegistry.getSegment(segmentId)?.locked ?? false; + + const maskLocked = (mask: SegmentMask) => segmentLocked(mask.segmentId); + + const isLocked = (maskId: string) => + segmentLocked(findMask(maskId)?.segmentId); + + /** + * 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 existing = ownSegment + ? undefined + : segmentRegistry.findSegmentByName(descriptor.name); + if (existing && !maskFor(parentImageId, 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, + fillOpacity: descriptor.fillOpacity, + outlineOpacity: descriptor.outlineOpacity, + }); + } + + /** + * Mints one mask per descriptor, each cropped to its value's voxels and + * holding SEGMENT_VALUE. + */ + 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 mask = createMask( + segmentation.id, + bindDescriptorSegment(parentImageId, descriptor, options.ownSegments) + ); + + const binding = createBindingForImage( + parentImageId, + extent, + options.source, + options.name + ); + attachMaskBinding(mask.id, binding); + created.push(mask); + + // 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; + } + + // Separate from the registry's mint cursor: a descriptor-less labelmap + // decodes to the same catalog however many segments were minted by hand. + const nextCategoricalColor = cycle(CATEGORICAL_COLORS); + const getNextDecodeColor = () => + [...nextCategoricalColor(), 255] as RGBAColor; + + 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; the joining caller's source and descriptions go unused. + const running = conversions.get(imageID)?.get(parentID); + 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 segment shared by other images. + descriptors.map((descriptor) => ({ + ...descriptor, + ...bySourceValue.get(descriptor.value), + })), + { source } + ); + return created.map((mask) => mask.id); + }, + }); + const ontoParents = + conversions.get(imageID) ?? new Map(); + conversions.set(imageID, ontoParents.set(parentID, conversion)); + try { + return await conversion; + } finally { + ontoParents.delete(parentID); + // The child is still converting while it goes onto another parent. + if (ontoParents.size === 0) { + conversions.delete(imageID); + convertingLabelmaps.delete(imageID); + } + } + } + + /** + * Starts a conversion nobody awaits, and reports its failure. A conversion + * outlives the load or the click that started it, and 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 mask = segmentation.masks[maskId]; + if (mask.representations.labelmap) return mask.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, + findMaskBinding, + getMask, + segmentationOfMask, + ensureLabelmapBinding, + maskLocked, + overlapAllowed: () => allowOverlap.value, + }); + + /** The image's masks in `order`, or none when it has no segmentation. */ + function imageMasks(parentImageId: string) { + const segmentation = getSegmentationForImage(parentImageId); + return segmentation ? listMasks(segmentation) : []; + } + + /** + * The masks of an image a process may edit: unlocked, since a locked segment + * is not editable, and holding voxels, since an empty mask has no content to + * process. + */ + function editableMasks(parentImageId: string) { + return imageMasks(parentImageId).flatMap((mask) => { + const binding = mask.representations.labelmap; + if (maskLocked(mask) || !binding || isEmptyExtent(binding.extent)) + return []; + return [{ maskId: mask.id, labelValue: SEGMENT_VALUE }]; + }); + } + + /** + * The ids of 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. + */ + const boundMaskIds = (parentImageId: string) => + imageMasks(parentImageId) + .filter((mask) => mask.representations.labelmap) + .map((mask) => mask.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 mask 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 targetSegmentId = (preferredSegmentId: Maybe) => + segmentRegistry.getSegment(preferredSegmentId)?.id ?? + segmentRegistry.selectedSegmentId.value; + + /** + * 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) { + return maskFor(imageId, targetSegmentId(preferredSegmentId))?.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) => + segmentLocked(targetSegmentId(preferredSegmentId)); + + /** + * Resolves or creates the mask an edit targets. With no segments the first + * edit mints one, 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 = + targetSegmentId(preferredSegmentId) ?? + segmentRegistry.ensureSelectedSegment(); + return ensureMask(imageId, segmentId).id; + } + + const maskIdsOfSegment = (segmentId: string) => + Object.values(segmentations).flatMap((segmentation) => { + const maskId = maskIdsBySegment.get(segmentation.id)?.get(segmentId); + return maskId ? [maskId] : []; + }); + + segmentRegistry.declareReferences('labelmaps', { + has: (segmentId) => maskIdsOfSegment(segmentId).length > 0, + remove: (segmentId) => + maskIdsOfSegment(segmentId).forEach((maskId) => deleteMask(maskId)), + }); + + // --- 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, + splitLabelmapIntoMasks, + decodeSegments, + convertImageToLabelmap, + startLabelmapConversion, + saveFormat, + allowOverlap, + voxelClaim, + imageMasks, + editableMasks, + boundMaskIds, + 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..3c5c6e18f 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,69 @@ 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 ALL segment masks when an image has several (no splice-skip)', () => { seatImage('img-1', 'CT'); - const segmentGroups = useSegmentGroupStore(); - const groupId = segmentGroups.newLabelmapFromImage('img-1'); - expect(groupId).not.toBeNull(); - expect(segmentGroups.orderByParent['img-1']).toContain(groupId); + 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([]); - 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('removes the segmentation and its masks with the parent image', () => { 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 { segmentationId, maskId } = seatMask('img-1'); + expect(boundMasks().map((mask) => mask.id)).toContain(maskId); 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(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 +182,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 +232,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 +263,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..730adda8f 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 JSZip from 'jszip'; +import type { Manifest } from '@/src/io/state-file/schema'; +import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; import { setActivePinia, createPinia } from 'pinia'; +import { mintSegment } from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { nextTick } from 'vue'; +import { STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT } 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,109 @@ describe('Ruler store', () => { // TODO testing jumpToRuler requires store integration // TODO testing (de)serialize requires store integration }); + +describe('Ruler segment references', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + const segments = () => useSegmentStore().segments; + + // Adding selects, so this is the segment a new ruler picks up. + const seedSegment = () => segments().addSegment({ name: 'Tumor' }); + + it('draws out of the shared registry', () => { + const store = useRulerStore(); + seedSegment(); + + const id = store.addRuler(createRuler()); + + expect(store.appearanceOfTool(id)).toMatchObject({ + name: 'Tumor', + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + }); + }); + + it('adds a ruler carrying the segment it was given', () => { + const store = useRulerStore(); + const segmentId = seedSegment(); + + 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 segment', () => { + const store = useRulerStore(); + const segmentId = seedSegment(); + + const id = store.addRuler(createRuler()); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + }); + + it('shows a rename on the rulers that reference the segment', async () => { + const store = useRulerStore(); + const segmentId = seedSegment(); + const id = store.addRuler({ ...createRuler(), segmentId }); + + 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 segment with it', async () => { + const store = useRulerStore(); + const segmentId = seedSegment(); + const id = store.addRuler({ ...createRuler(), segmentId }); + + segments().deleteSegment(segmentId); + await nextTick(); + + expect(store.rulerByID[id]).toBeUndefined(); + }); + + it('serializes ruler geometry with its shared segment reference', () => { + const store = useRulerStore(); + const segmentId = seedSegment(); + const ruler = createRuler(); + const id = store.addRuler({ ...ruler, segmentId }); + const manifest: Manifest = { + version: MANIFEST_VERSION, + dataSources: [], + tools: {}, + }; + + store.serialize({ zip: new JSZip(), manifest }); + + expect(manifest.tools?.rulers).toMatchObject({ + tools: [{ ...ruler, id, 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' }) + ); + + const id = store.addRuler({ + ...createRuler(), + segmentId: painted.segmentId, + }); + + expect(store.appearanceOfTool(id).name).toBe('Tumor'); + }); +}); 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..850fa9ae6 100644 --- a/src/store/tools/index.ts +++ b/src/store/tools/index.ts @@ -2,7 +2,7 @@ import { Manifest, StateFile } from '@/src/io/state-file/schema'; import { Maybe } from '@/src/types'; import type { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; import { defineStore } from 'pinia'; -import { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import { useCropStore } from './crop'; import { useCrosshairsToolStore } from './crosshairs'; import { usePaintToolStore } from './paint'; @@ -13,7 +13,9 @@ 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 { useCurrentImage } from '@/src/composables/useCurrentImage'; import { EffectiveView, getEffectiveView, @@ -31,6 +33,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) { @@ -120,6 +132,7 @@ function teardownTool(tool: Tools) { export const useToolStore = defineStore('tool', () => { const currentTool = ref(Tools.WindowLevel); const toolBeforeTemporaryCrosshairs = ref(currentTool.value); + const { currentImageID } = useCurrentImage('global'); function setCurrentTool(tool: Tools) { const coerced = coerceForEffective(tool, activeEffectiveView()); @@ -131,8 +144,22 @@ export const useToolStore = defineStore('tool', () => { } teardownTool(currentTool.value); currentTool.value = coerced; + // A shortcut can pick a tool up with no image, where a seat would linger. + if (SEGMENT_TOOLS.has(coerced) && currentImageID.value) { + useSegmentStore().segments.ensureSelectedSegment(); + } } + // Shared by the toolbar and the Paint hints, so no hint asks for a disabled tool. + const paintUnavailableReason = computed(() => { + if (!currentImageID.value) return 'Load an image to paint'; + const kind = activeEffectiveView()?.kind; + if (kind === 'cine') return 'A clip cannot be painted'; + if (kind === 'oblique') + return 'Painting is not available in an oblique view'; + return ''; + }); + function activateTemporaryCrosshairs() { if (!isToolAllowedFor(Tools.Crosshairs, activeEffectiveView())) return; toolBeforeTemporaryCrosshairs.value = currentTool.value; @@ -173,18 +200,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) { @@ -197,6 +220,7 @@ export const useToolStore = defineStore('tool', () => { return { currentTool, + paintUnavailableReason, setCurrentTool, serialize, deserialize, diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 9abe2e976..fd66855f6 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -1,41 +1,37 @@ +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'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; 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 +43,6 @@ export const usePaintToolStore = defineStore('paint', () => { const activeMode = ref(PaintMode.CirclePaint); const modeBeforeProcess = ref(PaintMode.CirclePaint); const processControlsOpen = ref(false); - const activeSegmentGroupID = ref>(null); - const activeSegment = ref>(null); const brushSize = ref(DEFAULT_BRUSH_SIZE); const strokePoints = ref([]); const isActive = ref(false); @@ -56,7 +50,6 @@ export const usePaintToolStore = defineStore('paint', () => { const crossPlaneSync = ref(false); const paintPosition = ref([0, 0, 0]); const activePaintViewID = ref>(null); - const lastSegmentByGroup = ref>({}); const { currentImageID, currentImageMetadata } = useCurrentImage('global'); const imageStatsStore = useImageStatsStore(); @@ -68,28 +61,13 @@ export const usePaintToolStore = defineStore('paint', () => { return this.$paint.factory; } - const segmentGroupStore = useSegmentGroupStore(); - - // Delete-base cleanup: removing a dataset cascades away its segment groups. - // `serialize` writes the raw `activeSegmentGroupID`, so null it the instant - // its record leaves the store or the save manifest carries an orphaned id. - // Sync flush keeps this within the same `datasetStore.remove` call — the same - // remove-cascade contract as onImageDeleted, but keyed on segmentGroupID (not - // imageID), so it watches the record set instead of using that composable. - watch( - () => - activeSegmentGroupID.value != null && - !(activeSegmentGroupID.value in segmentGroupStore.metadataByID), - (orphaned) => { - if (orphaned) activeSegmentGroupID.value = null; - }, - { flush: 'sync' } - ); + const segmentationStore = useSegmentationStore(); const isPaintingModeActive = computed( () => activeMode.value === PaintMode.CirclePaint || - activeMode.value === PaintMode.Erase + activeMode.value === PaintMode.Erase || + activeMode.value === PaintMode.Eyedropper ); const activePaintMode = computed(() => isPaintingModeActive.value ? activeMode.value : modeBeforeProcess.value @@ -141,71 +119,27 @@ export const usePaintToolStore = defineStore('paint', () => { } /** - * Sets the active labelmap. - */ - function setActiveSegmentGroup(segmentGroupID: Maybe) { - activeSegmentGroupID.value = segmentGroupID; - } - - function getValidSegmentGroupID(imageID: Maybe): Maybe { - if (!imageID) return null; - - // If current segment group belongs to this image, keep using it - if ( - activeSegmentGroupID.value && - segmentGroupStore.metadataByID[activeSegmentGroupID.value] - ?.parentImage === imageID - ) { - return activeSegmentGroupID.value; - } - - // Otherwise look for other segment groups for this image - const segmentGroups = segmentGroupStore.orderByParent[imageID]; - if (segmentGroups && segmentGroups.length > 0) { - return segmentGroups[0]; - } - return null; - } - - /** - * Sets the active labelmap from a given image. - * - * If a labelmap exists, pick one. If no labelmap exists, create one. - */ - function ensureActiveSegmentGroupForImage(imageID: Maybe) { - if (!imageID) { - setActiveSegmentGroup(null); - return; - } - - const segmentGroupID = - getValidSegmentGroupID(imageID) ?? - segmentGroupStore.newLabelmapFromImage(imageID); - setActiveSegmentGroup(segmentGroupID); - } - - /** - * Sets the active segment. - * - * If the segment may be null | undefined, indicating no paint will occur. - * @param segValue + * The segment this operation writes into. It is allocated for a stroke that + * writes voxels; an erase takes what is already there, so it resolves nothing + * into existence and refuses when there is nothing stored to take from. */ - function setActiveSegment(this: _This, segValue: Maybe) { - if (segValue) { - if (!activeSegmentGroupID.value) - throw new Error('Cannot set active segment without a labelmap'); - - const { segments } = - segmentGroupStore.metadataByID[activeSegmentGroupID.value]; - - if (!(segValue in segments.byValue)) - throw new Error('Segment is not available for the active labelmap'); - - lastSegmentByGroup.value[activeSegmentGroupID.value] = segValue; - } - - activeSegment.value = segValue; - this.$paint.setBrushValue(segValue); + function resolveStrokeTarget(imageID: string, allocate: boolean) { + if (![PaintMode.CirclePaint, PaintMode.Erase].includes(activeMode.value)) + return undefined; + const maskId = allocate + ? segmentationStore.resolveEditTarget(imageID) + : segmentationStore.findEditTarget(imageID); + if (!maskId) return undefined; + + const binding = allocate + ? segmentationStore.ensureLabelmapBinding(maskId) + : segmentationStore.findMaskBinding(maskId); + if (!binding) return undefined; + + return { + maskId, + voxels: segmentationStore.maskVoxels(maskId), + }; } /** @@ -218,69 +152,134 @@ 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; - - const labelmap = segmentGroupStore.dataIndex[segmentGroupID]; - if (!labelmap) 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); + } - // Prevent painting if active segment is locked or doesn't exist - if (activeSegment.value) { - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (!metadata) return; + /** + * The latest step of the stroke and the parent voxels it can touch, or + * nothing when it touches none. + */ + function latestStrokeStep( + this: _This, + axisIndex: 0 | 1 | 2, + parentImage: vtkImageData + ) { + const lastIndex = strokePoints.value.length - 1; + if (lastIndex < 0) return undefined; + + // 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()) + ); + // A press beside the image covers no voxel, so it must mint nothing. + if (isEmptyExtent(strokeExtent)) return undefined; + return { lastIndexPoint, prevIndexPoint, strokeExtent }; + } - const imageData = useImageCacheStore().getVtkImageData(imageID); - const underlyingImagePixels = imageData - ?.getPointData() + 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; + + const parentImage = useImageCacheStore().getVtkImageData(imageID); + if (!parentImage) return; + const step = latestStrokeStep.call(this, axisIndex, parentImage); + if (!step) return; + const { lastIndexPoint, prevIndexPoint, strokeExtent } = step; + + useSegmentationEditsStore().beforeEdit(); + const erasing = activeMode.value === PaintMode.Erase; + const target = resolveStrokeTarget(imageID, !erasing); + if (!target) return; + + const { voxels, maskId } = target; + this.$paint.setBrushValue(SEGMENT_VALUE); + const underlyingImagePixels = parentImage + .getPointData() .getScalars() .getData(); + + // Growth happens first, and nothing grows once the buffers below are read. + if (!erasing) { + voxels.ensureContains(strokeExtent, STROKE_GROWTH_PADDING); + } + + 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 neighbor. + 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] !== SEGMENT_VALUE) 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 neighbors, 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 +293,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 +346,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 +419,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 +433,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 +440,6 @@ export const usePaintToolStore = defineStore('paint', () => { activeMode, activePaintMode, processControlsOpen, - activeSegmentGroupID, - activeSegment, brushSize, strokePoints, isActive, @@ -499,13 +456,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..e2585fde1 100644 --- a/src/store/tools/polygons.ts +++ b/src/store/tools/polygons.ts @@ -6,7 +6,6 @@ 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'; @@ -38,7 +37,7 @@ const ensureVec2 = (regions: (Vec2 | Vec6)[][]) => { export const usePolygonStore = defineAnnotationToolStore('polygon', () => { const toolAPI = useAnnotationTool({ toolDefaults, - initialLabels: POLYGON_LABEL_DEFAULTS, + manifestKey: 'polygons', }); function getPoints(id: ToolID) { @@ -129,14 +128,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 +193,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..95fab58ad 100644 --- a/src/store/tools/rectangles.ts +++ b/src/store/tools/rectangles.ts @@ -1,7 +1,6 @@ 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 { @@ -19,15 +18,10 @@ const rectangleDefaults = () => ({ fillColor: 'transparent', }); -const newLabelDefault = { - fillColor: 'transparent', -}; - export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { const toolAPI = useAnnotationTool({ toolDefaults: rectangleDefaults, - initialLabels: RECTANGLE_LABEL_DEFAULTS, - newLabelDefault, + manifestKey: 'rectangles', }); function getPoints(id: ToolID) { @@ -42,8 +36,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..4952ce90c 100644 --- a/src/store/tools/rulers.ts +++ b/src/store/tools/rulers.ts @@ -4,7 +4,6 @@ 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 { @@ -24,7 +23,7 @@ const rulerDefaults = () => ({ export const useRulerStore = defineAnnotationToolStore('ruler', () => { const annotationTool = useAnnotationTool({ toolDefaults: rulerDefaults, - initialLabels: RULER_LABEL_DEFAULTS, + manifestKey: 'rulers', }); // prefix some props with ruler @@ -62,12 +61,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..3d56bfe3c 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 segment id -> restored segment id, for the tools that share + // the segment registry. + segmentIdMap: Record + ) => void; } export interface IToolStore diff --git a/src/store/tools/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index d9a348913..62b2d6204 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -1,10 +1,6 @@ -import { Ref, computed, ref, watch } from 'vue'; +import { Ref, computed, markRaw, ref } from 'vue'; import type { Vector3 } from '@kitware/vtk.js/types'; import type { Maybe, PartialWithRequired, UnwrapAll } from '@/src/types'; -import { - STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, - TOOL_COLORS, -} from '@/src/config'; import { isRecord, removeFromArray } from '@/src/utils'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { onImageDeleted } from '@/src/composables/onImageDeleted'; @@ -14,35 +10,44 @@ import { useIdStore } from '@/src/store/id'; import { useToolSelectionStore } from '@/src/store/tools/toolSelection'; import type { IToolStore } from '@/src/store/tools/types'; import { applyLocator } from '@/src/core/annotations/locator'; -import { useLabels, type Labels } from './useLabels'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useImageCacheStore } from '@/src/store/image-cache'; + +type AnnotationToolKey = 'rulers' | 'rectangles' | 'polygons'; // 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 const declareAnnotationToolManifestRefs = (key: AnnotationToolKey) => declareManifestRefs(`tools.${key}`, (manifest) => { const tools = isRecord(manifest.tools) ? manifest.tools : {}; const section = tools[key]; if (!isRecord(section) || !Array.isArray(section.tools)) return []; - return section.tools.flatMap((entry, index) => - isRecord(entry) && typeof entry.imageID === 'string' - ? [ - { - kind: 'dataset' as const, - id: entry.imageID, - where: `tools.${key}[${index}].imageID`, - }, - ] - : [] - ); + return section.tools.flatMap((entry, index) => { + if (!isRecord(entry)) return []; + return [ + ...(typeof entry.imageID === 'string' + ? [ + { + kind: 'dataset' as const, + id: entry.imageID, + where: `tools.${key}[${index}].imageID`, + }, + ] + : []), + ...(typeof entry.segmentId === 'string' && entry.segmentId + ? [ + { + kind: 'segment' as const, + id: entry.segmentId, + where: `tools.${key}[${index}].segmentId`, + }, + ] + : []), + ]; + }); }); -const annotationToolLabelDefault = Object.freeze({ - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT as number, -}); - const makeAnnotationToolDefaults = () => ({ frameOfReference: { planeOrigin: [0, 0, 0], @@ -51,23 +56,19 @@ const makeAnnotationToolDefaults = () => ({ slice: -1, imageID: '', placing: false, - color: TOOL_COLORS[0], - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, name: 'baseAnnotationTool', }); // Must return addTool in consuming Pinia store. export const useAnnotationTool = < MakeToolDefaults extends (...args: any) => any, - LabelProps, >({ toolDefaults, - initialLabels, - newLabelDefault, + manifestKey, }: { toolDefaults: MakeToolDefaults; - initialLabels: Labels; - newLabelDefault?: LabelProps; + // The manifest section this tool owns, which is also its reference-holder id. + manifestKey: AnnotationToolKey; }) => { type ToolDefaults = ReturnType; type Tool = ToolDefaults & AnnotationTool; @@ -88,21 +89,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 = useSegmentStore().segments; function addTool(tool: ToolPatch): ToolID { const id = useIdStore().nextId() as ToolID; @@ -113,10 +100,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 +109,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 +128,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 resolveToolSegment(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) { + resolveToolSegment(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 +158,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 +178,67 @@ 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 }) => { + // 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. + const mappedImageId = dataIDMap[imageID]; + if (!mappedImageId || !imageCache.imageById[mappedImageId]) + return false; + const mappedId = segmentId && segmentIdMap[segmentId]; + return !mappedId || registry.getSegment(mappedId); + }) .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)); + + registry.declareReferences(manifestKey, { + has: hasToolsOfSegment, + remove: removeToolsOfSegment, + }); + return { - ...labels, + segments: markRaw(registry), + appearanceOfTool, toolIDs, toolByID, tools, @@ -225,6 +246,8 @@ export const useAnnotationTool = < addTool, removeTool, updateTool, + resolveToolSegment, + placeTool, jumpToTool, serializeTools, deserializeTools, @@ -234,7 +257,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..05a9b7850 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) + // A layer can be gone by the time its preset is asked for. 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/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/color.ts b/src/utils/color.ts index e6d6a5517..e71f644ea 100644 --- a/src/utils/color.ts +++ b/src/utils/color.ts @@ -1,15 +1,5 @@ import type { RGBAColor } from '@kitware/vtk.js/types'; -/** A cursor over a palette: each call hands out the next opaque color. */ -export function cycleColors(palette: readonly (readonly number[])[]) { - let index = 0; - return () => { - const color = palette[index]; - index = (index + 1) % palette.length; - return [...color, 255] as RGBAColor; - }; -} - /** * Converts an RGBA tuple to a hex string with alpha. * 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/utils/imageSpace.ts b/src/utils/imageSpace.ts index 9882b8e44..1c70b9d89 100644 --- a/src/utils/imageSpace.ts +++ b/src/utils/imageSpace.ts @@ -3,9 +3,6 @@ import { areEquals } from '@kitware/vtk.js/Common/Core/Math'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import type { Vector3 } from '@kitware/vtk.js/types'; import { vec3 } from 'gl-matrix'; -import type { mat4 } from 'gl-matrix'; -import { getLPSDirections } from '@/src/utils/lps'; -import type { LPSAxis, LPSDirections } from '@/src/types/lps'; // give more fp tolerance due to transforms const RELAXED_EPSILON = EPSILON * 1e2; @@ -96,28 +93,3 @@ export function indexPointToWorld(image: vtkImageData, indexPoint: vec3): vec3 { vec3.transformMat4(worldPoint, indexPoint, image.getIndexToWorld()); return worldPoint; } - -/** - * Convert a slice index from source image space to target image space. - * Used when source and target have different coordinate systems. - */ -export function convertSliceIndex( - sourceSlice: number, - sourceLps: LPSDirections, - sourceIndexToWorld: mat4, - targetImage: vtkImageData, - axis: LPSAxis -): number { - const sourceIjkIndex = sourceLps[axis]; - const targetLps = getLPSDirections(targetImage.getDirection()); - const targetIjkIndex = targetLps[axis]; - - const sourceIndexPoint = vec3.fromValues(0, 0, 0); - sourceIndexPoint[sourceIjkIndex] = sourceSlice; - - const worldPoint = vec3.create(); - vec3.transformMat4(worldPoint, sourceIndexPoint, sourceIndexToWorld); - - const targetIndexPoint = worldPointToIndex(targetImage, worldPoint); - return Math.round(targetIndexPoint[targetIjkIndex]); -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 102161c72..e0023a54a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -96,6 +96,16 @@ export function partition( return partitioned; } +/** A cursor over `items`: each call hands out the next one, wrapping around. */ +export function cycle(items: readonly T[]) { + let index = 0; + return () => { + const item = items[index]; + index = (index + 1) % items.length; + return item; + }; +} + export function partitionByType( guard: (x: T) => x is U, arr: T[] 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/PolygonWidget/behavior.ts b/src/vtk/PolygonWidget/behavior.ts index 6fb2d7db9..ef0bb46ca 100644 --- a/src/vtk/PolygonWidget/behavior.ts +++ b/src/vtk/PolygonWidget/behavior.ts @@ -170,8 +170,14 @@ export default function widgetBehavior(publicAPI: any, model: any) { // Left press: Select handle to drag / Add new handle // -------------------------------------------------------------------------- + const canDragHandle = () => + model.activeState?.getActive() && + model.activeState?.setOrigin && + model.pickable && + model.dragable; + publicAPI.handleLeftButtonPress = (event: vtkMouseEvent) => { - if (!model.manipulator) { + if (!model.manipulator || anotherWidgetHasFocus()) { return macro.VOID; } @@ -213,12 +219,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { return macro.EVENT_ABORT; } - if ( - model.activeState?.getActive() && - model.activeState?.setOrigin && - model.pickable && - model.dragable - ) { + if (canDragHandle()) { setDragging(true); model._apiSpecificRenderWindow.setCursor('grabbing'); model._interactor.requestAnimation(publicAPI); 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..866579820 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) @@ -81,13 +82,22 @@ export default function widgetBehavior(publicAPI: any, model: any) { ); }; + const anotherWidgetIsActive = () => { + const active = model._widgetManager.getActiveWidget(); + return active && active !== publicAPI; + }; + const getWorldCoords = computeWorldCoords(model); /** * Places or drags a point. */ publicAPI.handleLeftButtonPress = (eventData: any) => { - if (!model.manipulator || shouldIgnoreEvent(eventData)) { + if ( + !model.manipulator || + anotherWidgetHasFocus() || + shouldIgnoreEvent(eventData) + ) { return macro.VOID; } @@ -105,12 +115,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { const intState = publicAPI.getInteractionState(); // If not placing and another widget is active, don't consume event. - const activeWidget = model._widgetManager.getActiveWidget(); - if ( - intState === InteractionState.Select && - activeWidget && - activeWidget !== publicAPI - ) { + if (intState === InteractionState.Select && anotherWidgetIsActive()) { return macro.VOID; } @@ -148,7 +153,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..eb069cb2b --- /dev/null +++ b/tests/fixtures/label-outline/scene.ts @@ -0,0 +1,157 @@ +import '@kitware/vtk.js/Rendering/Profiles/Volume'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { allocateMask, setMaskScalars } from '@/src/segmentation/masks/storage'; +import { maskScalars } from '@/src/segmentation/model'; +import { reframeMaskScalars, type Extent3D } from '@/src/segmentation/geometry'; +import { + segmentRenderMask, + type RenderMaskSlot, +} 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, + segmentOutline, +} 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 renderSlot: RenderMaskSlot = {}; +const mask = params.has('fullGrid') + ? allocateMask(parent, parent.getExtent() as Extent3D) + : segmentRenderMask(source, parent, extent, { + axis: axis, + index: 0, + slot: renderSlot, + })!; +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 = segmentOutline( + { + value: 1, + name: 'Mask', + visible: true, + locked: false, + color: [255, 0, 0, 255], + fillOpacity: 1, + outlineOpacity: 1, + }, + 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, + slot: renderSlot, + }); + 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..8182c390d 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 segmentGroupSaveButtons() { - return $$('button[data-testid="segment-group-save-button"]'); + get saveSegmentsButtons() { + return $$('button[data-testid="save-segments-button"]'); } - get segmentGroupList() { - return $('.segment-group-list'); + get segmentList() { + return $('[data-testid="segment-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,31 +277,12 @@ 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 $('[role="slider"][aria-label="Stroke Width"]'); } get editLabelModalDoneButton() { - return $('button[data-testid="edit-label-done-button"]'); + return $('button[data-testid="segment-editor-done-button"]'); } get datasetMenuButtons() { 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..57bf8d7ed --- /dev/null +++ b/tests/specs/annotations-sidebar.e2e.ts @@ -0,0 +1,402 @@ +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, + deleteSegment, + openAnnotationSegments, + lockSegment, + segmentRow, + openSegmentShapes, + renameSegment, + revealSegment, + segmentListTop, + segmentNames, + selectSegment, + selectedSegmentName, + shapeRowTexts, + waitForNamedSegments, + waitForSegmentContent, +} 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('deletes a segment with its painted mask and measurement and reports what was removed', async () => { + const { axialView } = await placeRectangle(); + await volViewPage.activatePaint(); + await volViewPage.paintStrokeOnView(axialView); + await openAnnotationSegments(); + await waitForSegmentContent('Segment 1'); + await expect(volViewPage.saveSegmentsButtons[0]).toBeEnabled(); + await openSegmentShapes(); + expect(await shapeRowTexts()).toHaveLength(1); + + await deleteSegment('Segment 1'); + expect(await segmentNames()).toEqual([]); + await expect($('[data-testid="segment-shape-row"]')).not.toExist(); + await expect(axialView.$('svg rect')).not.toExist(); + await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); + await volViewPage.notifications.click(); + await expect($('.message-center .header > span')).toHaveText( + 'Deleted 1 mask on 1 image and 1 annotation' + ); + }); + + 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..24bdb4b8d --- /dev/null +++ b/tests/specs/paint-eyedropper.e2e.ts @@ -0,0 +1,198 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { openUrls } from './utils'; +import { + addSegment, + allowOverlap, + deleteSegment, + openAnnotationSegments, +} from './segmentationTestUtils'; +import { nudgeTo, pressAtPointer } from './annotationTestUtils'; + +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'); +}; + +const openPaintedScene = async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.activatePaint(); + await openAnnotationSegments(); + const view = (await volViewPage.getViews2D())[0]; + const canvas = view.$('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').$('[data-testid="reveal-segment-button"]') + ).toBeEnabled(); + return { view, canvas, x, y }; +}; + +describe('Paint eyedropper', () => { + afterEach(async () => { + await browser.releaseActions(); + }); + + for (const [tool, icon] of [ + ['ruler', 'mdi-ruler'], + ['rectangle', 'mdi-vector-square'], + ]) { + it(`samples painted content after switching from a picked ${tool} handle`, async () => { + const { view, canvas, x, y } = await openPaintedScene(); + await addSegment(); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + await expect( + row('Segment 2').$('[data-testid="reveal-segment-button"]') + ).toBeDisabled(); + + await volViewPage.selectTool(icon); + await nudgeTo(x + 60, y - 60); + await pressAtPointer(); + await nudgeTo(x + 90, y - 30); + await pressAtPointer(); + await volViewPage.selectTool('mdi-cursor-default'); + await expect(view.$$('svg circle')).toBeElementsArrayOfSize(2); + await nudgeTo(x - 50, y + 50); + await browser.waitUntil( + async () => (await canvas.getCSSProperty('cursor')).value === 'default' + ); + await nudgeTo(x + 60, y - 60); + await browser.waitUntil( + async () => (await canvas.getCSSProperty('cursor')).value === 'pointer' + ); + + await volViewPage.activatePaint(); + await eyedropper().click(); + await expect(eyedropper()).toHaveAttribute('aria-pressed', 'true'); + await browser.waitUntil(async () => + String((await canvas.getCSSProperty('cursor')).value).startsWith('url(') + ); + await nudgeTo(x, y); + await pressAtPointer(); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + + // Removing the painted segment leaves only the annotation, with no mask to save. + await deleteSegment('Segment 1'); + await expect(row('Segment 2')).toExist(); + await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); + }); + } + + it('picks visible label maps without painting and restores the held mode', async () => { + const { canvas, x, y } = await openPaintedScene(); + 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..1fa62b340 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,31 +53,23 @@ 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(); + 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 AppPage.processPreviewButton.waitForClickable(); - await AppPage.processPreviewButton.click(); - - // Previewing starts on the processed result. - await AppPage.processProcessedButton.waitForDisplayed(); - await browser.waitUntil(() => - AppPage.isPreviewToggleActive(AppPage.processProcessedButton) - ); - expect( - 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. - await AppPage.processProcessedButton.click(); - await browser.waitUntil(() => - AppPage.isPreviewToggleActive(AppPage.processOriginalButton) - ); - expect( - await AppPage.isPreviewToggleActive(AppPage.processProcessedButton) - ).toBe(false); - }); + 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..50de73a2f --- /dev/null +++ b/tests/specs/polygon-rasterize-segment.e2e.ts @@ -0,0 +1,260 @@ +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, + pressAtPointer, + rightPressAtPointer, + setupTest, +} from './annotationTestUtils'; +import { + addSegment, + lockSegment, + openAnnotationSegments, + renameSegment, + segmentColor, + segmentNames, + selectSegment, + tooltipOf, + 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 openPolygonMenuAt = async ( + canvas: ChainablePromiseElement, + x: number, + y: number +) => { + // Every target is the square's top-right handle. Start outside its pickable + // fill so the cursor transition confirms the handle's pick has completed. + await nudgeTo(x + 20, y - 20); + await browser.waitUntil( + async () => (await canvas.getCSSProperty('cursor')).value === 'default' + ); + await nudgeTo(x, y); + await browser.waitUntil( + async () => (await canvas.getCSSProperty('cursor')).value === 'pointer' + ); + await rightPressAtPointer(); + await $(RASTERIZE_ITEM).waitForDisplayed(); +}; + +describe('Polygon rasterize target', () => { + it('rasterizes into the segment the polygon was drawn with', async () => { + const { axialView, centerX, centerY } = await setupTest(); + const canvas = axialView.$('canvas'); + const half = 60; + + // Paint a stroke first, so the image already holds a segment the polygon + // must not borrow. Selecting a tool creates no painted content. + 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 selectSegment('Segment 1'); + await openPolygonMenuAt(canvas, centerX + half, centerY - half); + await expect( + $('.v-overlay--active .v-list-item-title.v-list-item--disabled') + ).toHaveText('Lesion'); + 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); + await AppPage.activatePaint(); + await $('[data-testid="paint-eyedropper-button"]').click(); + await browser.waitUntil(async () => + String((await canvas.getCSSProperty('cursor')).value).startsWith('url(') + ); + await nudgeTo(centerX, centerY); + await pressAtPointer(); + await expect( + $('[data-testid="segment-list"] .item-row[aria-current="true"]') + ).toHaveAttribute('aria-label', 'Lesion'); + }); + + it('rasterizes a polygon drawn against an empty registry', async () => { + const { axialView, centerX, centerY } = await setupTest(); + const canvas = axialView.$('canvas'); + const half = 60; + + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY, half); + + await openAnnotationSegments(); + await expect(AppPage.saveSegmentsButtons[0]).toBeDisabled(); + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(canvas, centerX + half, centerY - half); + await $(RASTERIZE_ITEM).click(); + await expect(AppPage.saveSegmentsButtons[0]).toBeEnabled(); + + await openAnnotationSegments(); + await waitForNamedSegments(); + await browser.waitUntil(async () => (await segmentNames()).length === 1, { + timeoutMsg: + 'The polygon and its rasterized mask should share one segment', + }); + }); + + it('explains a locked rasterize target on hover and keyboard focus', async () => { + const { axialView, centerX, centerY } = await setupTest(); + const canvas = axialView.$('canvas'); + 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(canvas, 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 tooltipOf(activator); + await expect(tooltip).toBeDisplayed(); + await expect(tooltip).toHaveText( + 'Unlock this segment to rasterize into it' + ); + + await activator.execute((element) => element.focus()); + tooltip = await tooltipOf(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(canvas, 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 [restoredView] = await AppPage.getViews2D(); + await browser.waitUntil( + async () => { + const circles = await restoredView.$$('svg circle'); + return (await circles.length) === 4; + }, + { timeoutMsg: 'The restored polygon should render all four handles' } + ); + const restoredCanvas = restoredView.$('canvas'); + const [location, size] = await Promise.all([ + restoredCanvas.getLocation(), + restoredCanvas.getSize(), + ]); + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt( + restoredCanvas, + 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 tooltipOf(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 = 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(canvas, 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..9e1b49053 --- /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.$('.segment-editor-layout'); + const fields = dialog.$('.segment-editor-fields'); + const picker = dialog.$('.segment-editor-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..2f6839644 --- /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 downloadSegmentation = 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 downloadSegmentation(`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 downloadSegmentation(`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..c3700733f --- /dev/null +++ b/tests/specs/segment-overlap.e2e.ts @@ -0,0 +1,209 @@ +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, + waitForSegmentContent, + 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 openSaveDialog(); + const baseline = await saveSingleLayer(`replace-baseline-${Date.now()}`); + expect(baseline.foreground.length).toBeGreaterThan(0); + await paintNewSegment(); + await waitForSegmentContent('Segment 2'); + await openSaveDialog(); + + await expect(overlapNotice()).not.toBeDisplayed(); + const saved = await saveSingleLayer(`replace-${Date.now()}`); + expect(saved.segmentVoxels('Segment 2')).toEqual(baseline.foreground); + expect(saved.foreground).toEqual(baseline.foreground); + expect(saved.segmentVoxels('Segment 1')).toEqual([]); + }); + + 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('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(); + + await expect(overlapNotice()).toBeDisplayed(); + + 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-selection-fallback.e2e.ts b/tests/specs/segment-selection-fallback.e2e.ts new file mode 100644 index 000000000..45e94fbf7 --- /dev/null +++ b/tests/specs/segment-selection-fallback.e2e.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import JSZip from 'jszip'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import AppPage from '../pageobjects/volview.page'; +import { nudgeTo, pressAtPointer } from './annotationTestUtils'; +import { + openAnnotationSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; + +const SIZE = 32; +const STEM = 'selection-fallback'; + +const nrrd = (data: Uint8Array) => + 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, + ]); + +const wholeImageMask = (id: string, segmentId: string, name: string) => ({ + id, + segmentId, + representations: { + labelmap: { + path: `${id}.nrrd`, + name, + extent: [0, SIZE - 1, 0, SIZE - 1, 0, SIZE - 1], + }, + }, +}); + +// Both segments cover every voxel and the session names no selection. The +// eyedropper answers with the first segment covering a voxel, so it tells +// whether an erase took that voxel from First. +async function openUnselectedSegmentsWithPaint() { + const parent = new Uint8Array(SIZE ** 3).map((_, offset) => offset % 251); + fs.writeFileSync(path.join(TEMP_DIR, `${STEM}-parent.nrrd`), nrrd(parent)); + const zip = new JSZip(); + const full = nrrd(new Uint8Array(SIZE ** 3).fill(1)); + zip.file('first-mask.nrrd', full); + zip.file('second-mask.nrrd', full); + zip.file( + 'manifest.json', + JSON.stringify({ + version: '7.0.0', + dataSources: [{ id: 0, type: 'uri', uri: `/tmp/${STEM}-parent.nrrd` }], + segments: [ + { id: 'first', name: 'First', color: [255, 0, 0, 255] }, + { id: 'second', name: 'Second', color: [0, 0, 255, 255] }, + ], + segmentations: [ + { + id: 'segmentation', + name: 'Segments', + parentImage: '0', + order: ['first-mask', 'second-mask'], + masks: [ + wholeImageMask('first-mask', 'first', 'First'), + wholeImageMask('second-mask', 'second', 'Second'), + ], + }, + ], + tools: { current: 'Paint', paint: { brushSize: 8 } }, + }) + ); + fs.writeFileSync( + path.join(TEMP_DIR, `${STEM}.volview.zip`), + await zip.generateAsync({ type: 'nodebuffer' }) + ); + await AppPage.open(`?urls=[tmp/${STEM}.volview.zip]`); + await AppPage.waitForViews(); + await openAnnotationSegments(); + await waitForSegmentContent('First'); + await waitForSegmentContent('Second'); +} + +const selectedRow = () => + $('[data-testid="segment-list"] .item-row[aria-current="true"]'); + +describe('Selection with no segment chosen', () => { + it('highlights the first row and erases from it', async () => { + await openUnselectedSegmentsWithPaint(); + await expect(selectedRow()).toHaveAttribute('aria-label', 'First'); + + const [view] = await AppPage.getViews2D(); + const canvas = view.$('canvas'); + const [location, size] = await Promise.all([ + canvas.getLocation(), + canvas.getSize(), + ]); + const x = location.x + size.width / 2; + const y = location.y + size.height / 2; + + const erase = $('button.mode-button*=Erase'); + await erase.waitForClickable(); + await erase.click(); + await expect($('button.mode-button.selected')).toHaveText('Erase'); + await nudgeTo(x, y); + await pressAtPointer(); + + await $('[data-testid="paint-eyedropper-button"]').click(); + await browser.waitUntil(async () => + String((await canvas.getCSSProperty('cursor')).value).startsWith('url(') + ); + await nudgeTo(x, y); + await pressAtPointer(); + + await expect(selectedRow()).toHaveAttribute('aria-label', 'Second'); + }); +}); diff --git a/tests/specs/segment-shared-type.e2e.ts b/tests/specs/segment-shared-type.e2e.ts new file mode 100644 index 000000000..06a894511 --- /dev/null +++ b/tests/specs/segment-shared-type.e2e.ts @@ -0,0 +1,117 @@ +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, + segmentRow, + waitForSegmentContent, + 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); + + const secondTumor = await segmentRow('Tumor'); + await expect( + secondTumor.$('[data-testid="reveal-segment-button"]') + ).toBeDisabled(); + await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); + await paintOnViewedImage(); + await waitForSegmentContent('Tumor'); + await expect(volViewPage.saveSegmentsButtons[0]).toBeEnabled(); + 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']); + const firstTumor = await segmentRow('Tumor A'); + await expect( + firstTumor.$('[data-testid="reveal-segment-button"]') + ).toBeDisabled(); + await paintOnViewedImage(); + await waitForSegmentContent('Tumor A'); + await expectSegments(['Segment 1', 'Tumor A']); + + await showImage(second); + await waitForSegmentContent('Tumor A'); + expect(await segmentColor('Tumor A')).toEqual(tumorColor); + await showImage(first); + await waitForSegmentContent('Tumor A'); + }); +}); diff --git a/tests/specs/segment-shortcuts.e2e.ts b/tests/specs/segment-shortcuts.e2e.ts new file mode 100644 index 000000000..50e8c489a --- /dev/null +++ b/tests/specs/segment-shortcuts.e2e.ts @@ -0,0 +1,237 @@ +import * as path from 'node:path'; +import fs from 'node:fs'; +import JSZip from 'jszip'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { + openUrls, + openVolViewPage, + waitForDownload, + SESSION_SAVE_TIMEOUT, +} from './utils'; +import { + addSegment, + openAnnotationSegments, + segmentNames, + selectSegment, +} 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('cycles forward and backward across segment boundaries for polygon and paint', async () => { + await volViewPage.open(); + await expect(volViewPage.samplesList).toBeDisplayed(); + await browser.keys('w'); + await browser.keys('q'); + await expect(volViewPage.samplesList).toBeDisplayed(); + await expect($('[data-testid="segment-list"]')).not.toExist(); + expect(await volViewPage.getNotificationsCount()).toBe(0); + + await openUrls([ONE_CT_SLICE_DICOM]); + await openAnnotationSegments(); + await browser.keys('w'); + await browser.keys('q'); + expect(await segmentNames()).toEqual([]); + + for (let index = 0; index < 3; index++) await addSegment(); + for (const icon of ['mdi-pentagon-outline', 'mdi-brush']) { + await volViewPage.selectTool(icon); + await selectSegment('Segment 1'); + await browser.keys('q'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 3'); + await browser.keys('w'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + await browser.keys('w'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); + } + }); + + it('cycles from the first row when a restored session names no selection', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await openAnnotationSegments(); + for (let index = 0; index < 3; index++) await addSegment(); + const saved = await volViewPage.saveSession(); + const savedPath = path.join(TEMP_DIR, saved); + 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; + zip.file('manifest.json', JSON.stringify(manifest)); + const name = 'segment-shortcuts-no-selection.volview.zip'; + const filePath = path.join(TEMP_DIR, name); + fs.writeFileSync(filePath, await zip.generateAsync({ type: 'nodebuffer' })); + cleanuptotal.addCleanup(async () => fs.rmSync(filePath, { force: true })); + + for (const [key, expected] of [ + ['w', 'Segment 2'], + ['q', 'Segment 3'], + ]) { + await openVolViewPage(name); + await openAnnotationSegments(); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + await browser.keys(key); + await expect(selected()).toHaveAttribute('aria-label', expected); + } + }); + + for (const [tool, icon] of [ + ['Paint', 'mdi-brush'], + ['Polygon', 'mdi-pentagon-outline'], + ['Rectangle', 'mdi-vector-square'], + ['Ruler', 'mdi-ruler'], + ]) { + it(`keeps the selection when ${tool} is reactivated without creating painted content`, async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.selectTool(icon); + await openAnnotationSegments(); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + expect(await segmentNames()).toEqual(['Segment 1']); + await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); + await volViewPage.selectTool('mdi-cursor-default'); + await volViewPage.selectTool(icon); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + expect(await segmentNames()).toEqual(['Segment 1']); + + await addSegment(); + await volViewPage.selectTool('mdi-cursor-default'); + await volViewPage.selectTool(icon); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 2'); + expect(await segmentNames()).toEqual(['Segment 1', 'Segment 2']); + await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); + }); + } + + 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 waitForDownload(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..154211b78 --- /dev/null +++ b/tests/specs/segment-tooltips.e2e.ts @@ -0,0 +1,74 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from '../datasets'; +import { moveTo } from './annotationTestUtils'; +import { + openAnnotationSegments, + renameSegment, + segmentRow, + tooltipOf, + waitForSegmentContent, +} from './segmentationTestUtils'; +import { openUrls } from './utils'; + +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 tooltipOf(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 tooltipOf(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 tooltipOf(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 tooltipOf(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..067a3b1a5 --- /dev/null +++ b/tests/specs/segmentationTestUtils.ts @@ -0,0 +1,176 @@ +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. +export const segmentNames = () => + $$(`${SEGMENT_LIST} .item-row .v-list-item-title`).map((title) => + title.getText() + ); + +const findRow = async (name: string) => { + const rows = await $$(`${SEGMENT_LIST} .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. +export const segmentRow = async (name: string) => { + const row = await browser.waitUntil(() => findRow(name), { + timeoutMsg: `No segment named "${name}"`, + }); + return row as NonNullable; +}; + +export const segmentColor = async (name: string) => { + const row = await segmentRow(name); + const dot = await row.$('.color-dot'); + return (await dot.getCSSProperty('background-color')).value; +}; + +export const addSegment = async () => { + const before = await segmentNames(); + await $(`${SEGMENT_LIST} .create-row`).click(); + await browser.waitUntil( + async () => (await segmentNames()).length === before.length + 1, + { timeoutMsg: 'Expected the create row to add a segment' } + ); +}; + +const editDialog = () => $('div[role="dialog"]'); + +const renameInOpenDialog = async (to: string) => { + const dialog = editDialog(); + await dialog.waitForDisplayed(); + // The segment editor puts its name field before the numeric fields. + await setValueVueInput(dialog.$('.v-text-field input'), to); + await volViewPage.editLabelModalDoneButton.click(); + await dialog.waitForDisplayed({ reverse: true }); +}; + +/** + * 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 segmentRow(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 selectSegment = async (name: string) => { + const row = await segmentRow(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; + +export const deleteSegment = async (name: string) => { + const row = await segmentRow(name); + await row.$('button i[class~="mdi-delete"]').click(); + await browser.waitUntil(async () => !(await segmentNames()).includes(name), { + timeoutMsg: `Expected segment "${name}" to be removed`, + }); +}; + +export const revealSegment = async (name: string) => { + const row = await segmentRow(name); + const button = await row.$('button[data-testid="reveal-segment-button"]'); + await button.waitForClickable(); + await button.click(); +}; + +export const renameSegment = async (from: string, to: string) => { + const row = await segmentRow(from); + await row.$('button[data-testid="edit-segment-button"]').click(); + await renameInOpenDialog(to); + await browser.waitUntil(async () => (await segmentNames()).includes(to), { + timeoutMsg: `Expected a segment named "${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 segmentRow(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..ba3d6a1b0 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -87,35 +87,50 @@ export async function openVolViewPage(fileName: string) { export const SESSION_SAVE_TIMEOUT = 40_000; -export const waitForFileExists = (filePath: string, timeout: number) => +const waitForFile = ( + filePath: string, + timeout: number, + ready: (stats: fs.Stats) => boolean +) => new Promise((resolve, reject) => { const dir = path.dirname(filePath); const basename = path.basename(filePath); - - const watcher = fs.watch(dir, (eventType, filename) => { - if (eventType === 'rename' && filename === basename) { + const check = () => { + try { + if (!ready(fs.statSync(filePath))) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; clearTimeout(timerId); watcher.close(); - resolve(); + reject(error); + return; } + clearTimeout(timerId); + watcher.close(); + resolve(); + }; + const watcher = fs.watch(dir, (_, filename) => { + if (filename === null || filename === basename) check(); }); - const timerId = setTimeout(() => { watcher.close(); - reject( - new Error(`File ${filePath} not created within ${timeout}ms timeout`) - ); + reject(new Error(`File ${filePath} was not ready within ${timeout}ms`)); }, timeout); - - fs.access(filePath, fs.constants.R_OK, (err) => { - if (!err) { - clearTimeout(timerId); - watcher.close(); - resolve(); - } + watcher.on('error', (error) => { + clearTimeout(timerId); + watcher.close(); + reject(error); }); + check(); }); +export const waitForFileExists = (filePath: string, timeout: number) => + waitForFile(filePath, timeout, () => true); + +// Download creation and its first bytes share the caller's existing wait budget. +export const waitForDownload = (filePath: string, timeout: number) => + waitForFile(filePath, timeout, (stats) => stats.size > 0); + export async function openUrls(datasets: ReadonlyArray) { const manifest = { resources: datasets.map(({ name }) => ({ url: `/tmp/${name}` })),