diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 26b207ff6..153448927 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -9,6 +9,10 @@ jobs: name: Code check and tests steps: - uses: actions/checkout@v4 + with: + # The ratchets diff against the merge base with the target branch, so + # the checkout needs history rather than a single commit. + fetch-depth: 0 - name: Setup node uses: actions/setup-node@v4 with: @@ -19,6 +23,16 @@ jobs: run: npx prettier --list-different "src/**/*.[jt]s" "tests/**/*.[jt]s" "src/**/*.vue" - name: Lint run: npm run lint + - name: Quality ratchets + # On a pull request the checkout is the merge commit, so the merge base + # with the target branch is what the change is measured against. The + # merge queue has no pull request to read a base from. + env: + BASE_REF: ${{ github.event.pull_request.base.ref || 'main' }} + run: | + git fetch --no-tags origin \ + "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" + node scripts/checks/check.mjs --base "origin/$BASE_REF" - name: Tests run: npm test diff --git a/backend-contract/README.md b/backend-contract/README.md index 20e03d85c..0c8fcda94 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) @@ -106,8 +110,23 @@ Two versions on separate clocks: `info.version`, kept in lockstep by `processing/__tests__/openapi.spec.ts`. Versions this package as a published thing. - **Shape versions**: `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) and - the task-spec `specVersion`. These version the wire vocabulary for additive - compatibility negotiation. + the task-spec `specVersion`. These name the shape of the wire vocabulary in + the generated OpenAPI description and in release notes. Neither travels on + the wire, so neither is negotiated: additive compatibility rests on both + sides failing open on a value they do not know. + +### Result instruction rollout + +Contract artifact 0.3.0 uses intent vocabulary 3 and names segmentation import +`import-segmentation`. Deploy the updated producer and VolView client together. +An older client treats the unfamiliar instruction as an ordinary result and +will not apply its segmentation automatically. Update Girder's pinned VolView +package when releasing the paired change. + +This vocabulary change does not change task-spec versions or saved-session +schemas. Girder projects stored job outputs into current instructions when +results are requested; stored output references and mask provenance keep their +identities. ## Regenerating diff --git a/backend-contract/fixtures/negative/wrong-length-color.json b/backend-contract/fixtures/negative/wrong-length-color.json index f5aa4fee1..cc483faa6 100644 --- a/backend-contract/fixtures/negative/wrong-length-color.json +++ b/backend-contract/fixtures/negative/wrong-length-color.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e1", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", "name": "otsu.nii.gz", "segments": [ diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json b/backend-contract/fixtures/wire/intent.import-segmentation.embedded.json similarity index 88% rename from backend-contract/fixtures/wire/intent.add-segment-group.embedded.json rename to backend-contract/fixtures/wire/intent.import-segmentation.embedded.json index 5c6f74ba9..a0a1da4c5 100644 --- a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json +++ b/backend-contract/fixtures/wire/intent.import-segmentation.embedded.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e2", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e2/proxiable/threshold.seg.nrrd", "name": "threshold.seg.nrrd", "source": { diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json b/backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json similarity index 95% rename from backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json rename to backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json index fa0f30949..87cb530d3 100644 --- a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json +++ b/backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e1", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", "name": "otsu.nii.gz", "segments": [ diff --git a/backend-contract/generated/annotations-file.schema.json b/backend-contract/generated/annotations-file.schema.json index 1652ded68..0665f6b05 100644 --- a/backend-contract/generated/annotations-file.schema.json +++ b/backend-contract/generated/annotations-file.schema.json @@ -24,6 +24,7 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { @@ -46,6 +47,7 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { @@ -68,6 +70,7 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json index 79b8f1c1f..7b651e668 100644 --- a/backend-contract/generated/job-results.schema.json +++ b/backend-contract/generated/job-results.schema.json @@ -114,7 +114,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index c9b2ee04e..76b2e46f5 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -3,8 +3,8 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "VolView neutral backend contract", - "version": "0.2.0", - "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 2 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." + "version": "0.3.0", + "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 3 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." }, "servers": [ { @@ -717,7 +717,7 @@ } }, "multiple": { - "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.", + "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. For a labelmap input the client stages the active image's segmentation as overlap-free parts: when true, every mask, spread across as many files as it takes for no two masks to share a voxel; when absent or false, only the non-conflicting subset that fits one file, with the remaining masks omitted whole and reported to the user. No mask is ever clipped to fit.", "type": "boolean" } }, @@ -1203,7 +1203,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", @@ -1701,6 +1701,7 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { @@ -1723,6 +1724,7 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { @@ -1745,6 +1747,7 @@ "type": "object", "properties": { "color": { + "description": "A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.", "type": "string" }, "strokeWidth": { diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json index e0b27e58b..9ba4f9ec8 100644 --- a/backend-contract/generated/result-intent.schema.json +++ b/backend-contract/generated/result-intent.schema.json @@ -102,7 +102,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index 9e08b404f..fa94c3c8b 100644 --- a/backend-contract/generated/task-spec.schema.json +++ b/backend-contract/generated/task-spec.schema.json @@ -310,7 +310,7 @@ } }, "multiple": { - "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.", + "description": "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. For a labelmap input the client stages the active image's segmentation as overlap-free parts: when true, every mask, spread across as many files as it takes for no two masks to share a voxel; when absent or false, only the non-conflicting subset that fits one file, with the remaining masks omitted whole and reported to the user. No mask is ever clipped to fit.", "type": "boolean" } }, diff --git a/backend-contract/package.json b/backend-contract/package.json index 1afe62c13..f1a07f920 100644 --- a/backend-contract/package.json +++ b/backend-contract/package.json @@ -1,5 +1,5 @@ { "name": "@volview/backend-contract", - "version": "0.2.0", + "version": "0.3.0", "private": true } diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts index 603c1bed6..a472b54ef 100644 --- a/backend-contract/processing/__tests__/wire.spec.ts +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -194,12 +194,12 @@ describe('neutral job status fixtures', () => { // --------------------------------------------------------------------------- describe('result intent fixtures', () => { - it('exports vocabulary version 2 and the exactly-four state intents', () => { - expect(INTENT_VOCABULARY_VERSION).toBe(2); + it('exports vocabulary version 3 and the exactly-four state intents', () => { + expect(INTENT_VOCABULARY_VERSION).toBe(3); expect([...RESULT_INTENTS]).toEqual([ 'add-base-image', 'add-layer', - 'add-segment-group', + 'import-segmentation', 'add-annotations', ]); expect(wire).not.toHaveProperty('intent.download'); @@ -208,8 +208,8 @@ describe('result intent fixtures', () => { it.each([ 'intent.add-base-image', 'intent.add-layer', - 'intent.add-segment-group.with-segments', - 'intent.add-segment-group.embedded', + 'intent.import-segmentation.with-segments', + 'intent.import-segmentation.embedded', 'intent.add-annotations', 'intent.unknown', ])('validates %s', (name) => { @@ -253,11 +253,11 @@ describe('result intent fixtures', () => { ).toBe(false); }); - it('parses add-segment-group WITH segments and a source provenance tag', () => { + it('parses import-segmentation WITH segments and a source provenance tag', () => { const parsed = resultIntentSchema.parse( - wire['intent.add-segment-group.with-segments'] + wire['intent.import-segmentation.with-segments'] ) as Record; - expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.intent).toBe('import-segmentation'); expect(Array.isArray(parsed.segments)).toBe(true); expect(parsed.source).toEqual({ providerId: 'analysis-provider', @@ -266,18 +266,18 @@ describe('result intent fixtures', () => { }); }); - it('parses add-segment-group WITHOUT segments (embedded metadata) but with source', () => { + it('parses import-segmentation WITHOUT segments (embedded metadata) but with source', () => { const parsed = resultIntentSchema.parse( - wire['intent.add-segment-group.embedded'] + wire['intent.import-segmentation.embedded'] ) as Record; - expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.intent).toBe('import-segmentation'); expect(parsed.segments).toBeUndefined(); expect(parsed.source).toMatchObject({ outputId: 'outputLabelmap' }); }); it('rejects a segment-group source without provider identity', () => { const value = structuredClone( - wire['intent.add-segment-group.with-segments'] + wire['intent.import-segmentation.with-segments'] ) as { source: { providerId?: string } }; delete value.source.providerId; expect(knownResultIntentSchema.safeParse(value).success).toBe(false); @@ -373,7 +373,7 @@ describe('result intent fixtures', () => { expect(knownResultIntentSchema.safeParse(short).success).toBe(false); expect(resultIntentSchema.safeParse(short).success).toBe(true); - const good = wire['intent.add-segment-group.with-segments'] as { + const good = wire['intent.import-segmentation.with-segments'] as { segments: { color: number[] }[]; }; const long = structuredClone(good); diff --git a/backend-contract/processing/annotations.ts b/backend-contract/processing/annotations.ts index f846a1d8a..06394de6f 100644 --- a/backend-contract/processing/annotations.ts +++ b/backend-contract/processing/annotations.ts @@ -89,7 +89,12 @@ export type WirePolygon = z.infer; // A label's style. Every field is optional: a label may exist purely as a name. export const annotationLabelSchema = z.strictObject({ - color: z.string().optional(), + color: z + .string() + .optional() + .describe( + 'A hex colour such as `#d60000` or a CSS colour keyword such as `lime`. Any other syntax, including functional forms such as `rgb()` and `hsl()`, is ignored: the label keeps the colour the client already holds for it, and the client tells the user the value was rejected.' + ), strokeWidth: z.number().optional(), fillColor: z.string().optional(), }); diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts index 8a62458d6..e9bda206d 100644 --- a/backend-contract/processing/openapi.ts +++ b/backend-contract/processing/openapi.ts @@ -474,7 +474,7 @@ export const buildOpenApiDocument = (): Record => ({ // VERSION / specVersion) below. It is deliberately literal, not derived from // the shape-version constants — the artifact and the shapes version on // separate clocks. - version: '0.2.0', + version: '0.3.0', description: 'DRAFT 0.x — shapes may change until a second backend passes the ' + 'conformance kit (the pinned 1.0 criterion). ' + diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts index 0097d72bc..588009acf 100644 --- a/backend-contract/processing/task-spec.ts +++ b/backend-contract/processing/task-spec.ts @@ -136,7 +136,7 @@ const sourceRefParam = z.object({ .boolean() .optional() .describe( - 'When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.' + "When true, the parameter takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. For a labelmap input the client stages the active image's segmentation as overlap-free parts: when true, every mask, spread across as many files as it takes for no two masks to share a voxel; when absent or false, only the non-conflicting subset that fits one file, with the remaining masks omitted whole and reported to the user. No mask is ever clipped to fit." ), }); diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 27c2d41ba..32e43e145 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -18,10 +18,14 @@ import { } from './task-spec'; import { pathSegmentIdSchema } from './ids'; -// Bump when the intent vocabulary's shape changes so producers and the applier -// can negotiate compatibility. Adding an intent is a compatible bump: an older -// client demotes the unknown intent through the fail-open branch above. -export const INTENT_VOCABULARY_VERSION = 2; +// A client-side marker for the shape of the intent vocabulary below, bumped +// when that shape changes. It never travels on the wire: no request header, +// response field, or schema property carries it, so the two sides never see +// each other's value and cannot negotiate on it. It names the vocabulary in +// the generated OpenAPI description and in this package's release notes. +// Adding an intent stays compatible without it: an older client demotes the +// unknown intent through the fail-open branch above. +export const INTENT_VOCABULARY_VERSION = 3; // --------------------------------------------------------------------------- // Input value: what the client sends at submit @@ -158,7 +162,7 @@ export type NeutralJobStatus = z.infer; export const RESULT_INTENTS = [ 'add-base-image', 'add-layer', - 'add-segment-group', + 'import-segmentation', 'add-annotations', ] as const; export type ResultIntentName = (typeof RESULT_INTENTS)[number]; @@ -212,13 +216,13 @@ const addLayer = z .object({ intent: z.literal('add-layer'), ...resultListItemSchema.shape }) .passthrough(); -// `add-segment-group` carries OPTIONAL `segments` (the bare-labelmap + +// `import-segmentation` carries OPTIONAL `segments` (the bare-labelmap + // labels-sidecar case; a `seg.nrrd` with embedded metadata carries none — the // client uses `segments` when present, else the file's own metadata) and an // optional `source` provenance tag (the idempotency key). const addSegmentGroup = z .object({ - intent: z.literal('add-segment-group'), + intent: z.literal('import-segmentation'), ...resultListItemSchema.shape, segments: z.array(segmentDescriptorSchema).optional(), source: resultSourceSchema.optional(), diff --git a/docs/configuration_file.md b/docs/configuration_file.md index bff0f0fd6..bc6c968da 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -4,7 +4,7 @@ By loading a JSON file, you can set VolView's configuration: - View layouts (grid size, view types, or hierarchical layouts) - Disabled view types -- Labels for tools +- Segments - Visibility of Sample Data section - Keyboard shortcuts @@ -149,26 +149,69 @@ Use `disabledViewTypes` to prevent certain view types from being available in th This removes the specified view types from the dropdown menu and replaces them in the default layout with allowed types. Valid values: `"2D"`, `"3D"`, `"Oblique"` -## Labels for tools +## Segments -Each tool type (Rectangle, Polygon, etc.) can have tool specific labels. To share labels -across tools, define the `defaultLabels` key and don't provide labels for a tool that -should use the default labels. +Paint, rectangles, polygons and rulers share one registry of segments, configured under +`segments`. Each entry is keyed by name, and every appearance field is optional: an +omitted one means the app default for a new segment. For an existing session segment, +omitted fields keep the appearance it had before configuration. Replacing a config entry +removes its previous appearance overrides, including color, while keeping the segment id, +visibility and lock state. + +```json +{ + "segments": { + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3, "fillOpacity": 0.5 } + } +} +``` + +Fields: `color`, `fillOpacity`, `outlineOpacity`, `strokeWidth`. + +Omitting the key leaves the registry alone. An empty record (`{}`) or `null` clears what an +earlier config contributed, keeping any segment your content still references with its +last configured appearance. A configured +segment keeps its id across config changes, so renaming or recoloring one never detaches +the masks and shapes that reference it. + +### Pre-7.0 `labels` + +A pre-7.0 `labels` section still loads. Its `defaultLabels`, `rulerLabels`, +`rectangleLabels` and `polygonLabels` all describe the one registry now, so they read as +`segments` entries. A name that appears in more than one becomes a single segment: the +first record to declare it sets its appearance, reading `rulerLabels`, `rectangleLabels` +and `polygonLabels` in that order and `defaultLabels` last, since it stood in only for the +tools that declared no record of their own. A rectangle label's `fillColor` is dropped, +since fill color is a property of the rectangle rather than of the segment. A config +carrying both `segments` and `labels` has been converted already, so `segments` is read +and `labels` is ignored. + +Converting a config by hand: ```json { "labels": { - "defaultLabels": { - "lesion": { "color": "#ff0000" }, - "tumor": { "color": "green", "strokeWidth": 3 } - } + "defaultLabels": { "lesion": { "color": "#ff0000" } }, + "rulerLabels": { "big": { "color": "#ff0000" } } + } +} +``` + +becomes + +```json +{ + "segments": { + "lesion": { "color": "#ff0000" }, + "big": { "color": "#ff0000" } } } ``` -## Segment Group File Format +## Session Mask File Format -The `segmentGroupSaveFormat` key specifies the file extension of the segment group images +The `segmentGroupSaveFormat` key specifies the file extension of the mask images VolView will include in the volview.zip file. ```json @@ -179,36 +222,49 @@ VolView will include in the volview.zip file. } ``` -Working segment group file formats: +Working mask file formats: hdf5, iwi.cbor, mha, nii, nii.gz, nrrd, vtk -## Automatic Layers and Segment Groups by File Name +## Automatic Layers and Segmentations by File Name When loading multiple files, VolView can automatically associate related images based on file naming patterns. Example: `base.[extension].nrrd` will match `base.nii`. The extension must appear anywhere in the filename after splitting by dots, and the filename must start with the same prefix as the base image (everything before the first dot). Files matching `base.[extension]...` will be associated with a base image named `base.*`. -**Ordering:** When multiple layers/segment groups match a base image, they are sorted alphabetically by filename and added to the stack in that order. To control the stacking order explicitly, you could use numeric prefixes in your filenames. +**Ordering:** When multiple layers/segmentations match a base image, they are sorted alphabetically by filename and added to the stack in that order. To control the stacking order explicitly, you could use numeric prefixes in your filenames. For example, with a base image `patient001.nrrd`: - Layers (sorted alphabetically): `patient001.layer.1.pet.nii`, `patient001.layer.2.ct.mha`, `patient001.layer.3.overlay.vtk` -- Segment groups: `patient001.seg.1.tumor.nii.gz`, `patient001.seg.2.lesion.mha` +- Segmentations: `patient001.seg.1.tumor.nii.gz`, `patient001.seg.2.lesion.mha` Both features default to `''` which disables them. -### Segment Groups +### Configuration migration + +Use `io.segmentationExtension` in new configuration. The old +`io.segmentGroupExtension` key is accepted at ingestion and converted to the +new key. If both keys are present, their values must match; conflicting values +are rejected. An explicit empty string disables automatic matching. + +The value `seg` is the filename marker in `patient.seg.nii.gz`; `nii.gz` is +its encoding extension. This setting preserves the existing filename matching +rule and does not add support for additional segmentation formats. + +Directly loading an old key in VolView also reports a deprecation warning. + +### Segmentations -Use `segmentGroupExtension` to automatically convert matching non-DICOM images to segment groups. -For example, `myFile.seg.nrrd` becomes a segment group for `myFile.nii`. +Use `segmentationExtension` to automatically convert matching non-DICOM images to segmentations. +For example, `myFile.seg.nrrd` becomes a segmentation for `myFile.nii`. Defaults to `''` which disables matching. ```json { "io": { - "segmentGroupExtension": "seg" + "segmentationExtension": "seg" } } ``` @@ -246,11 +302,9 @@ To configure a key for an action, add its action name and the key(s) under the ` ```json { - "labels": { - "defaultLabels": { - "lesion": { "color": "#ff0000" }, - "tumor": { "color": "green", "strokeWidth": 3 } - } + "segments": { + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3 } }, "layouts": { "single-view": { @@ -264,28 +318,10 @@ To configure a key for an action, add its action name and the key(s) under the ` ```json { - "labels": { - "defaultLabels": { - "lesion": { "color": "#ff0000" }, - "tumor": { "color": "green", "strokeWidth": 3 }, - "innocuous": { "color": "white" } - }, - "rulerLabels": { - "big": { "color": "#ff0000" }, - "small": { "color": "white" } - }, - "rectangleLabels": { - "red": { "color": "#ff0000", "fillColor": "transparent" }, - "green": { "color": "green", "fillColor": "transparent" }, - "white-yellow-fill": { - "color": "white", - "fillColor": "#00ff0030" - } - }, - "polygonLabels": { - "poly1": { "color": "#ff0000" }, - "poly2Label": { "color": "green" } - } + "segments": { + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3, "fillOpacity": 0.5 }, + "innocuous": { "color": "white", "outlineOpacity": 0.8 } }, "layouts": { "Volume primary": { @@ -313,7 +349,7 @@ To configure a key for an action, add its action name and the key(s) under the ` }, "io": { "segmentGroupSaveFormat": "nrrd", - "segmentGroupExtension": "seg", + "segmentationExtension": "seg", "layerExtension": "layer" } } diff --git a/docs/loading_data.md b/docs/loading_data.md index b67ed18d3..dce2ce0c8 100644 --- a/docs/loading_data.md +++ b/docs/loading_data.md @@ -70,4 +70,4 @@ To layer images: ## State Files -Load preconfigured scenes with annotations, segment groups, and view settings via [state files](./state_files.md). State files can embed data (`*.volview.zip`) or reference remote data via URIs (`*.volview.json`). +Load preconfigured scenes with annotations, segmentations, and view settings via [state files](./state_files.md). State files can embed data (`*.volview.zip`) or reference remote data via URIs (`*.volview.json`). diff --git a/docs/server.md b/docs/server.md index 297ac4d97..1c887bc0d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -11,7 +11,7 @@ directly. For longer-running work, VolView also ships a Jobs panel that talks to a processing backend over the neutral API defined in the `backend-contract` package: the backend advertises its tasks, VolView builds the submission form from each task specification, and completed outputs load back into the scene as -images, layers, or segment groups. Any service that implements the contract +images, layers, or segmentations. Any service that implements the contract works, since VolView knows only the shared vocabulary and never a backend's native task format. diff --git a/docs/state_files.md b/docs/state_files.md index 0f7f9972a..9ecf93009 100644 --- a/docs/state_files.md +++ b/docs/state_files.md @@ -14,7 +14,11 @@ 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: +Legacy 6.2.0 manifest example (still supported on import): + +The historical `segmentGroups` field is migrated into the current segmentation +model. A segmentation owns an image's segment masks; labelmaps encode those +masks for storage or interchange. New sessions use the current schema. ```json { diff --git a/docs/toolbar.md b/docs/toolbar.md index ca1aa6cd7..f3dbd4bde 100644 --- a/docs/toolbar.md +++ b/docs/toolbar.md @@ -14,11 +14,21 @@ Window / Level, Pan, Zoom, or Crosshairs: Select these options to control the fu ## 2D Annotations -The "Annotations" tab lists the drawn, vector based, annotation tools. Each tool in the list has a "scroll to slice" and delete button. +The "Annotations" tab lists segments shared by paint, rectangles, polygons and rulers. +Select a segment in the list or use `q` and `w` to cycle through segments. Use "New +segment" to add one, and its color dot or edit button to change its name and appearance. +The selection applies across all four tools and images. + +Expand a segment to see its shapes on the current image. Each shape has controls to +jump to its slice or cine frame and to delete it. A segment's Reveal button jumps to +its mask or shapes on the current image; it stays disabled when there is no content. ### Paint -When the paint tool is selected, you can paint in any 2D window. Click on the paint tool a second time to bring up a menu of colors and adjust the brush size. Painting automatically switches to the appropriate segment group for the volume being painted. +When the paint tool is selected, you can paint in any supported 2D slice window. +Choose the segment in "Annotations" and use the Paint controls below the segment +list to adjust brush size, switch to erasing, or set an intensity threshold. +Painting adds a mask for the selected segment on the image being painted. ### Rectangle @@ -26,7 +36,7 @@ When the rectangle tool is selected, the left mouse button is used to place and Right click a rectangle control point to delete the rectangle. The "Annotations" tab lists all rectangles and provides jump-to and delete controls. -Rectangle annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New rectangles use the selected segment from "Annotations". ### Polygon @@ -44,70 +54,39 @@ After closing a polygon: - Delete point: right click point and select Delete Point. - Delete polygon: right click point or line and select Delete Polygon. -Polygon annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New polygons use the selected segment from "Annotations". ### Ruler -When the ruler tool selected, the left mouse button is used to place and adjust ruler end-markers. Right clicking on a end-marker displays a pop-up menu for deleting that ruler. Switch to the "Annotations" tab to see a list of annotations made to currently loaded data. Select the location icon next to a listed ruler to jump to its slice. Select the trashcan to delete that ruler. +When the ruler tool is selected, the left mouse button places and adjusts ruler +end-markers. Right clicking an end-marker displays a menu for deleting that ruler. +Expand its segment in "Annotations" to see its length, jump to its slice or cine +frame, or delete it. -Ruler annotations can be tagged with a label. Use the palette in the upper left or the `q` or `w` keys to select the active label. +New rulers use the selected segment from "Annotations". ![2D Annotations](./assets/11-volview-paint-notes.jpg) -### Label Configuration - -If VolView loads a JSON file matching the schemas below, labels are added to the 2D annotation tools. -Example configuration JSON: - -```json -{ - "labels": { - "rulerLabels": { - "big": { "color": "#ff0000" }, - "small": { "color": "white" } - }, - "rectangleLabels": { - "innocuous": { "color": "white", "fillColor": "#00ff0030" }, - "lesion": { "color": "#ff0000", "fillColor": "transparent" }, - "tumor": { "color": "green", "fillColor": "transparent" } - } - } -} -``` +### Segment configuration -Label sections could be null to disable labels for a tool. +If VolView loads a JSON file matching the schema below, segments are added to the +registry. Paint, rectangles, polygons and rulers all share `segments`. Appearance +fields are optional. See [segment configuration](./configuration_file.md#segments) +for replacement behavior and how omitted fields use session appearance or defaults. ```json { - "labels": { - "rulerLabels": null, - "rectangleLabels": { - "innocuous": { - "color": "white", - "fillColor": "#00ff0030" - }, - "lesion": { - "color": "#ff0000", - "fillColor": "transparent" - } - } + "segments": { + "innocuous": { "color": "white" }, + "lesion": { "color": "#ff0000" }, + "tumor": { "color": "green", "strokeWidth": 3 } } } ``` -Tools will fallback to `defaultLabels` section if the tool has no specific labels property, -ie `rectangleLabels` or `rulerLabels`. - -```json -{ - "labels": { - "defaultLabels": { - "artifact": { "color": "gray" }, - "needs-review": { "color": "#FFBF00" } - } - } -} -``` +The section can be `null` or `{}` to clear what an earlier config contributed. A segment your +content still references survives as a session segment rather than taking its masks and +shapes with it. ## 3D Crop diff --git a/eslint.config.js b/eslint.config.js index 287844321..6c09904e9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,4 @@ +import path from 'node:path'; import js from '@eslint/js'; import eslintPluginVue from 'eslint-plugin-vue'; import tseslint from 'typescript-eslint'; @@ -21,6 +22,25 @@ import globals from 'globals'; // the full pattern set its files need, and no block silently erases another // feature's boundary. // --------------------------------------------------------------------------- +// A pure file may sit at the feature root (model.ts), one level down (masks/) +// or two (editing/algorithms/), and the relative spelling of an upper module +// differs at each depth. Collect the directories pure files live in so the +// deny-list can carry the spelling each of them would actually write. +const pureDirs = (feature) => + new Set( + feature.pure.files.flatMap((file) => { + const dir = path.posix.dirname(file.slice(`src/${feature.dir}/`.length)); + if (!dir.includes('*')) return [dir === '.' ? '' : dir]; + const base = dir.replace(/\/?\*+.*$/, ''); + return [base, `${base}/*`]; + }) + ); +const relativeSpellings = (feature, mod) => + [...pureDirs(feature)].map((dir) => { + const spelling = path.posix.relative(dir, mod); + return spelling.startsWith('.') ? spelling : `./${spelling}`; + }); + // `pure.upperModules` is a hand-maintained list of the feature's non-pure // modules: a new one has to be added here or the pure layer may import it. const featureBoundaries = (features) => { @@ -28,8 +48,11 @@ const featureBoundaries = (features) => { group: [`@/src/${dir}/*`, `@/src/${dir}/*/**`, `!@/src/${dir}/index`], message: `Import the ${dir} feature only from its public surface \`@/src/${dir}\` (src/${dir}/index.ts). A deep import bypasses the feature boundary.`, }); + const publicFeatures = features.filter( + (feature) => feature.publicSurface !== false + ); const otherSurfaces = (feature) => - features.filter((other) => other !== feature).map(publicSurface); + publicFeatures.filter((other) => other !== feature).map(publicSurface); return [ { @@ -38,7 +61,7 @@ const featureBoundaries = (features) => { rules: { 'no-restricted-imports': [ 'error', - { patterns: features.map(publicSurface) }, + { patterns: publicFeatures.map(publicSurface) }, ], }, }, @@ -65,13 +88,15 @@ const featureBoundaries = (features) => { patterns: [ { group: [ - ...feature.pure.upperModules.flatMap((mod) => [ - `@/src/${feature.dir}/${mod}`, - `./${mod}`, - `../${mod}`, - ]), + ...new Set( + feature.pure.upperModules.flatMap((mod) => [ + `@/src/${feature.dir}/${mod}`, + ...relativeSpellings(feature, mod), + ]) + ), '@/src/store/**', '@/src/components/**', + '@/src/composables/**', ], message: `The ${feature.dir} pure layer must not import stores, components, or upper feature modules — dependencies point downward only.`, }, @@ -136,6 +161,17 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-function-type': 'off', }, }, + // Node tooling (scripts/checks/, fixture servers). The block above matches + // only .js/.ts/.vue, so without this eslint reports `process`, `console` and + // `URL` as undefined in every .mjs file. + { + files: ['**/*.mjs'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.node, + }, + }, { files: ['**/tests/pageobjects/**/*.ts'], rules: { @@ -193,6 +229,43 @@ export default tseslint.config( }, }, ...featureBoundaries([ + { + dir: 'segmentation', + pure: { + files: [ + 'src/segmentation/geometry.ts', + 'src/segmentation/color.ts', + 'src/segmentation/model.ts', + 'src/segmentation/segment.ts', + 'src/segmentation/masks/storage.ts', + 'src/segmentation/masks/overlap.ts', + 'src/segmentation/masks/labelValue.ts', + 'src/segmentation/editing/algorithms/fillHoles.ts', + 'src/segmentation/editing/algorithms/fillHoles.worker.ts', + 'src/segmentation/editing/algorithms/gaussianSmooth.worker.ts', + ], + upperModules: [ + 'store', + 'segments', + 'segmentRegistry', + 'segmentReferences', + 'masks/voxelAccess', + 'io/**', + 'rendering/**', + 'editing/coordinator', + 'editing/paintProcess', + 'editing/processWorker', + 'editing/fillHoles', + 'editing/fillBetween', + 'editing/gaussianSmooth', + 'editing/rasterizePolygon', + 'components/**', + 'composables/**', + ], + }, + // Consumers import explicit modules; the pure-layer rule still applies. + publicSurface: false, + }, { dir: 'processing', pure: { @@ -204,9 +277,10 @@ export default tseslint.config( upperModules: [ 'store', 'applyResults', - 'jobResultReview', + 'annotationKinds', 'index', 'components/**', + 'composables/**', ], }, }, @@ -217,9 +291,54 @@ export default tseslint.config( 'src/referenceLines/geometry.ts', 'src/referenceLines/crossings.ts', ], - upperModules: ['store', 'index', 'useReferenceLines', 'components/**'], + upperModules: [ + 'store', + 'index', + 'useReferenceLines', + 'ReferenceLines.vue', + 'components/**', + ], }, }, ]), + // Tests are excluded so this block never matches a file the `vi.mock` block + // above matches: flat config replaces a rule's options wholesale, so overlap + // would erase that rule rather than add to it. + { + files: ['src/**/*.{ts,vue}'], + ignores: ['src/**/__tests__/**', 'src/**/*.{spec,test}.{js,ts}'], + rules: { + 'no-restricted-syntax': [ + 'warn', + { + selector: + "CallExpression[callee.name='computed'][typeArguments.params.length>0]", + message: + 'Let computed() infer its type. An explicit generic goes stale and hides the inference errors it was meant to document.', + }, + ], + }, + }, + // Mirrors the limits scripts/checks/complexity.mjs enforces per commit. Warn + // level because the ratchet only fails a file whose debt grows, so existing + // files stay over these numbers without failing `npm run lint`. + { + files: ['src/**/*.{js,ts,vue}'], + ignores: [ + 'src/**/__tests__/**', + 'src/**/*.{spec,test}.{js,ts}', + 'src/**/*.d.ts', + '**/emscripten-build/**', + ], + rules: { + complexity: ['warn', 10], + 'max-depth': ['warn', 3], + 'max-params': ['warn', 4], + 'max-lines': [ + 'warn', + { max: 600, skipBlankLines: true, skipComments: true }, + ], + }, + }, eslintConfigPrettier ); diff --git a/package-lock.json b/package-lock.json index 3726d47dd..09c74feb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,14 @@ "@itk-wasm/dicom": "^7.6.4", "@itk-wasm/image-io": "1.6.1", "@itk-wasm/morphological-contour-interpolation": "2.0.0", - "@kitware/vtk.js": "^36.2.0", + "@kitware/vtk.js": "^37.0.0", "@netlify/edge-functions": "^3.0.2", "@rollup/plugin-replace": "^6.0.3", "@sentry/vite-plugin": "^4.6.1", "@sentry/vue": "^10.27.0", "@thi.ng/api": "^8.12.9", "@thi.ng/rasterize": "^1.0.171", + "@types/color-name": "^2.0.0", "@types/cors": "^2.8.19", "@types/deep-equal": "^1.0.4", "@types/express": "^5.0.5", @@ -37,6 +38,7 @@ "@wdio/spec-reporter": "^9.20.0", "@wdio/static-server-service": "^9.20.0", "@wdio/visual-service": "9.0.2", + "color-name": "^1.1.4", "comlink": "^4.4.2", "concurrently": "^10.0.4", "core-js": "3.47.0", @@ -54,6 +56,7 @@ "globals": "^16.2.0", "happy-dom": "^20.8.9", "itk-wasm": "^1.0.0-b.199", + "jscpd": "5.1.2", "jszip": "3.10.1", "lint-staged": "16.2.7", "mitt": "^3.0.1", @@ -3250,9 +3253,9 @@ } }, "node_modules/@kitware/vtk.js": { - "version": "36.2.1", - "resolved": "https://registry.npmjs.org/@kitware/vtk.js/-/vtk.js-36.2.1.tgz", - "integrity": "sha512-u5V7jfYeve1WNXVl7bZbjp755t1/EMyiW2y0aQU7YZ7RteCfZ8mxk9jB3DTeo9pGcIsVr+E8SIQz2bXHHedPlg==", + "version": "37.0.0", + "resolved": "https://registry.npmjs.org/@kitware/vtk.js/-/vtk.js-37.0.0.tgz", + "integrity": "sha512-E2NLixO00bBd7r+pyhGx3iNIWh9nthFeEBw1B9fSOsrYhYmE1JwRyXkeL2wnBsnup5z1qkSEylhHsepiHzhSZw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3260,9 +3263,8 @@ "commander": "9.2.0", "d3-scale": "4.0.2", "fast-deep-equal": "3.1.3", - "fflate": "0.7.3", + "fflate": "0.7.5", "gl-matrix": "3.4.3", - "globalthis": "1.0.3", "seedrandom": "3.0.5", "shelljs": "0.8.5", "spark-md5": "3.0.2", @@ -3276,7 +3278,7 @@ }, "peerDependencies": { "autoprefixer": "^10.4.7", - "wslink": ">=1.1.0 || ^2.0.0" + "wslink": ">=2.0.0" } }, "node_modules/@msgpack/msgpack": { @@ -5762,6 +5764,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/color-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-2.0.0.tgz", + "integrity": "sha512-63mTjolMJv75upGaUbT6J3lRDWl6pETPQsaWni9w3dMArhNBpgtHkX8ISb9zLV3YYLPA/SMk8ZGALa3k9WY/aQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -11989,9 +11998,9 @@ } }, "node_modules/fflate": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz", - "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", "dev": true, "license": "MIT" }, @@ -12705,22 +12714,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -14556,6 +14549,144 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jscpd": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.1.2.tgz", + "integrity": "sha512-7innyzaMstgJcluIwLxUESsP7znUFtRxiENhcUzv34aDrjzH3mJOF7m/5ecNyQGST8BBlmz8alpTuId0c6OdkQ==", + "dev": true, + "license": "MIT", + "bin": { + "jscpd": "run-jscpd.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://opencollective.com/jscpd" + }, + "optionalDependencies": { + "jscpd-darwin-arm64": "5.1.2", + "jscpd-darwin-x64": "5.1.2", + "jscpd-linux-arm64-gnu": "5.1.2", + "jscpd-linux-arm64-musl": "5.1.2", + "jscpd-linux-x64-gnu": "5.1.2", + "jscpd-linux-x64-musl": "5.1.2", + "jscpd-windows-arm64-msvc": "5.1.2", + "jscpd-windows-x64-msvc": "5.1.2" + } + }, + "node_modules/jscpd-darwin-arm64": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.1.2.tgz", + "integrity": "sha512-6fYVCBHgE154jVVYcRA+9K17q5AXe0/V9wfPf3hBrDQMxqOiI+99ii2CfMT6OkcEtJULqpi+uJENCsjtYn+nmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/jscpd-darwin-x64": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.1.2.tgz", + "integrity": "sha512-udNKAqu7ty6NcHvSwaZ7qCktyN+JwET0Z81e6/s1WmRtHkdd6Hnv0Gix04gRN3aVkfqZzI6YKle0YnYPJe4hOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/jscpd-linux-arm64-gnu": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.1.2.tgz", + "integrity": "sha512-FYX3IZPXwEooiDn7fQzHEytRbbsSBNqqHUEzxu32EKBXNyQ1Fx1/8hms26X1RFUJ80zpcKkRQCz1J3hPYsYhFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-linux-arm64-musl": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-musl/-/jscpd-linux-arm64-musl-5.1.2.tgz", + "integrity": "sha512-SuoKN9xv4pIopLS2yPx3E+ysnmZPNAKdxxAYTwvd6ixrEcO36xIKUma+DGKUw1PLXq1HO6A9M3FlqmnrTCnzYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-linux-x64-gnu": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.1.2.tgz", + "integrity": "sha512-1B+rqrw7Jt7/5Sye7ssedRnN8I1qatjUq9tFRcUQvlteR1OgW4lZuGV+DTYOgNaPNzeJN21aLzBGBa9OsxRWMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-linux-x64-musl": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.1.2.tgz", + "integrity": "sha512-46Astr0LnVh/prpqq5/Vehtx18MCvcs0KL8vi49kAIksOe6QdlQ/hdBKH8V61pAAmAgCr64hU9p0dv6DDKHJdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/jscpd-windows-arm64-msvc": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-windows-arm64-msvc/-/jscpd-windows-arm64-msvc-5.1.2.tgz", + "integrity": "sha512-19gpnY+Mid71EGGytBcFwsif/Y1+Vfu2Qp0IJ+L3WFD32Zui9O4vvASwvxocgA7z77kiZNh9zF5sZY9+oQB62A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/jscpd-windows-x64-msvc": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.1.2.tgz", + "integrity": "sha512-RZlAnXP7Jmx39LBC66IIpFIGl/Bg4FcSkz4Jr6ThrDm5D1NvM201MDG70Z1i+N3wRrfI8UcDkWp5rsHzGHViSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", diff --git a/package.json b/package.json index b6a8af5bd..4daadbd65 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:e2e:chrome": "cross-env VITE_SHOW_SAMPLE_DATA=true npm run build && wdio run ./wdio.chrome.conf.ts", "test:e2e:chrome:skip-build": "wdio run ./wdio.chrome.conf.ts", "test:e2e:dev": "cross-env VITE_SHOW_SAMPLE_DATA=true concurrently -P \"npm run dev\" \"wdio run ./wdio.dev.conf.ts --watch {@}\"", - "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\"", + "lint": "vue-tsc --noEmit && eslint \"src/**/*.{js,ts,vue}\" \"tests/**/*.{js,ts}\" \"scripts/**/*.mjs\"", "build:all": "npm run build:dicom && npm run build:resample && npm run build", "build:dicom": "itk-wasm -s src/io/itk-dicom/ build ", "build:dicom:debug": "itk-wasm -s src/io/itk-dicom/ build -- -DCMAKE_BUILD_TYPE=Debug", @@ -40,13 +40,14 @@ "@itk-wasm/dicom": "^7.6.4", "@itk-wasm/image-io": "1.6.1", "@itk-wasm/morphological-contour-interpolation": "2.0.0", - "@kitware/vtk.js": "^36.2.0", + "@kitware/vtk.js": "^37.0.0", "@netlify/edge-functions": "^3.0.2", "@rollup/plugin-replace": "^6.0.3", "@sentry/vite-plugin": "^4.6.1", "@sentry/vue": "^10.27.0", "@thi.ng/api": "^8.12.9", "@thi.ng/rasterize": "^1.0.171", + "@types/color-name": "^2.0.0", "@types/cors": "^2.8.19", "@types/deep-equal": "^1.0.4", "@types/express": "^5.0.5", @@ -63,6 +64,7 @@ "@wdio/spec-reporter": "^9.20.0", "@wdio/static-server-service": "^9.20.0", "@wdio/visual-service": "9.0.2", + "color-name": "^1.1.4", "comlink": "^4.4.2", "concurrently": "^10.0.4", "core-js": "3.47.0", @@ -80,6 +82,7 @@ "globals": "^16.2.0", "happy-dom": "^20.8.9", "itk-wasm": "^1.0.0-b.199", + "jscpd": "5.1.2", "jszip": "3.10.1", "lint-staged": "16.2.7", "mitt": "^3.0.1", @@ -113,11 +116,11 @@ "zod": "^4.1.13" }, "gitHooks": { - "pre-commit": "lint-staged", + "pre-commit": "lint-staged && node scripts/checks/check.mjs", "commit-msg": "commitlint --edit $1" }, "lint-staged": { - "*.{js,jsx,ts,tsx,vue}": [ + "*.{js,jsx,ts,tsx,vue,mjs}": [ "eslint", "prettier --write" ] diff --git a/scripts/checks/.gitignore b/scripts/checks/.gitignore new file mode 100644 index 000000000..0a2101fab --- /dev/null +++ b/scripts/checks/.gitignore @@ -0,0 +1 @@ +/cache/ diff --git a/scripts/checks/check.mjs b/scripts/checks/check.mjs new file mode 100644 index 000000000..e4dd858b3 --- /dev/null +++ b/scripts/checks/check.mjs @@ -0,0 +1,23 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +if (process.env.CHECKS_SKIP === '1') { + console.log('Additional checks skipped (CHECKS_SKIP=1).'); +} else { + for (const script of [ + 'complexity.mjs', + 'duplication.mjs', + 'conventions.mjs', + ]) { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL(script, import.meta.url)), + ...process.argv.slice(2), + ], + { stdio: 'inherit' } + ); + if (result.error) throw result.error; + if (result.status !== 0) process.exitCode = 1; + } +} diff --git a/scripts/checks/complexity.mjs b/scripts/checks/complexity.mjs new file mode 100755 index 000000000..977054074 --- /dev/null +++ b/scripts/checks/complexity.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Complexity ratchet: a changed file may not carry more debt than it did at +// the base ref, and a new file must carry none. Debt is the sum of +// (measured - limit) over every violation, so growing an already oversized +// file fails even though its violation count stays at one. +// +// complexity.mjs --base main compares the index with merge-base(main, HEAD). +import { mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { git, materialize, stagedContext } from './git.mjs'; +import { limits as LIMITS } from './config.mjs'; +const SOURCE = /^src\/.*\.(ts|js|vue)$/; +const EXCLUDED = /(__tests__|\.(spec|test)\.|\.d\.ts$|emscripten-build)/; + +const { base, baseRef, changed: entries, worktree } = stagedContext(); +const sources = entries.filter( + ({ file }) => SOURCE.test(file) && !EXCLUDED.test(file) +); +const changed = sources.map(({ file }) => file); +if (changed.length === 0) process.exit(0); + +const req = createRequire(path.join(worktree, 'package.json')); +const { ESLint } = req('eslint'); +const vueParser = req('vue-eslint-parser'); +const { parser: tsParser } = req('typescript-eslint'); + +const rules = Object.fromEntries( + Object.entries(LIMITS).map(([rule, limit]) => [ + rule, + [ + 'error', + rule === 'max-lines' + ? { max: limit, skipBlankLines: true, skipComments: true } + : limit, + ], + ]) +); + +const lint = async (cwd) => { + const eslint = new ESLint({ + cwd, + overrideConfigFile: true, + errorOnUnmatchedPattern: false, + allowInlineConfig: false, + overrideConfig: [ + { + files: ['**/*.{ts,js,vue}'], + languageOptions: { + parser: vueParser, + parserOptions: { + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module', + extraFileExtensions: ['.vue'], + }, + }, + rules, + }, + ], + }); + const results = await eslint.lintFiles(['**/*.{ts,js,vue}']); + return results.map((r) => ({ + file: path.relative(cwd, r.filePath), + messages: r.messages, + })); +}; + +// Core rule messages carry the measured value as "(N)" or "of N." and the +// limit as "Maximum allowed is N". +const debtOf = (message) => { + const measured = message.match(/\((\d+)\)|of (\d+)\./); + const limit = message.match(/Maximum allowed is (\d+)/); + if (!measured || !limit) + throw new Error(`Unrecognized rule diagnostic: ${message}`); + return Number(measured[1] ?? measured[2]) - Number(limit[1]); +}; + +const debtByFile = (results) => + Object.fromEntries( + results.map(({ file, messages }) => [ + file, + messages.reduce((acc, m) => { + if (!m.ruleId) throw new Error(`${file}:${m.line}: ${m.message}`); + const rule = m.ruleId; + return { ...acc, [rule]: (acc[rule] ?? 0) + debtOf(m.message) }; + }, {}), + ]) + ); + +const tmp = mkdtempSync(path.join(tmpdir(), 'ratchet-')); +try { + const candRoot = path.join(tmp, 'cand'); + const baseRoot = path.join(tmp, 'base'); + materialize( + candRoot, + sources.map(({ file }) => [file, git('show', `:${file}`)]) + ); + materialize( + baseRoot, + sources.map(({ file, before }) => [ + file, + before === undefined ? undefined : git('show', `${base}:${before}`), + ]) + ); + + const [cand, before] = await Promise.all([lint(candRoot), lint(baseRoot)]); + const candDebt = debtByFile(cand); + const baseDebt = debtByFile(before); + + const regressions = changed.flatMap((file) => + Object.entries(candDebt[file] ?? {}) + .filter(([rule, debt]) => debt > (baseDebt[file]?.[rule] ?? 0)) + .map(([rule, debt]) => ({ + file, + rule, + debt, + was: baseDebt[file]?.[rule] ?? 0, + })) + ); + + if (regressions.length > 0) { + console.error( + '\nComplexity ratchet: these files got worse than %s.\n', + baseRef + ); + regressions.forEach(({ file, rule, was, debt }) => { + console.error(` ${file} ${rule} debt ${was} -> ${debt}`); + const offending = cand.find((r) => r.file === file)?.messages ?? []; + offending + .filter((m) => (m.ruleId ?? 'parse-error') === rule) + .forEach((m) => console.error(` ${file}:${m.line} ${m.message}`)); + }); + console.error( + `\nLimits: ${Object.entries(LIMITS) + .map(([r, l]) => `${r} ${l}`) + .join( + ', ' + )}. Reduce the debt to at most the base value, or split the file.` + + `\nBypass additional checks for one commit with CHECKS_SKIP=1.\n` + ); + process.exitCode = 1; + } +} finally { + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/scripts/checks/config.mjs b/scripts/checks/config.mjs new file mode 100644 index 000000000..d8346404c --- /dev/null +++ b/scripts/checks/config.mjs @@ -0,0 +1,8 @@ +export const limits = { + complexity: 10, + 'max-depth': 3, + 'max-params': 4, + 'max-lines': 600, +}; + +export const minCloneTokens = 60; diff --git a/scripts/checks/conventions.mjs b/scripts/checks/conventions.mjs new file mode 100644 index 000000000..aac4bffb2 --- /dev/null +++ b/scripts/checks/conventions.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// Two checks that need no judgement: no committed binaries outside the +// directories that hold them, and no drift between a feature's modules on disk +// and the hand-maintained upper-module list its pure layer is guarded against. +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { git, stagedContext } from './git.mjs'; + +// The directories that already track binaries: visual baselines, documentation +// and application images, the favicons, and the itk wasm build output. Editing +// one of those is routine; a binary anywhere else is what this check is for. +const BINARY_DIRS = [ + 'tests/baseline/', + 'docs/assets/', + 'docs/public/', + 'public/', + 'src/assets/', + 'src/io/itk-dicom/emscripten-build/', + 'src/io/resample/emscripten-build/', +]; +const { base, changed, worktree } = stagedContext(); + +const failures = []; + +// git reports "-" for added and deleted lines when a blob is binary. +const numstat = git('diff', '--cached', '--numstat', base, '--'); +numstat + .split('\n') + .filter(Boolean) + .map((line) => line.split('\t')) + .filter(([added, removed, file]) => added === '-' && removed === '-' && file) + .filter(([, , file]) => !BINARY_DIRS.some((dir) => file.startsWith(dir))) + .forEach(([, , file]) => + failures.push( + `${file} is a binary file. Generate test data (tests/specs/syntheticDicom.ts) or download it lazily; committed binaries belong in ${BINARY_DIRS.join(', ')}.` + ) + ); + +// Staged content, like complexity.mjs and duplication.mjs: an unstaged config +// edit must not satisfy the gate for a commit that does not carry it. +const config = git('show', ':eslint.config.js'); +const features = [...config.matchAll(/dir: '([^']+)',\s*pure: \{/g)].map( + (match) => match[1] +); +const listAfter = (dir) => { + const start = config.indexOf(`dir: '${dir}'`); + const open = config.indexOf('upperModules: [', start); + const close = config.indexOf(']', open); + return (config.slice(open, close).match(/'[^']+'/g) ?? []).map((entry) => + entry.slice(1, -1) + ); +}; +const pureFilesFor = (dir) => { + const start = config.indexOf(`dir: '${dir}'`); + const open = config.indexOf('files: [', start); + const close = config.indexOf(']', open); + return (config.slice(open, close).match(/'[^']+'/g) ?? []).map((entry) => + entry.slice(1, -1) + ); +}; + +// Only modules added by this change: pre-existing gaps are debt, not a regression. +const added = new Set( + changed.filter(({ before }) => before === undefined).map(({ file }) => file) +); +// Every module file under src/, nested ones included: a coordinator added +// under editing/ or masks/ needs an upperModules entry just as a top-level one does. +const modulesUnder = (dir, prefix = '') => + readdirSync(path.join(worktree, 'src', dir, prefix), { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith('__')) + .flatMap((entry) => { + if (entry.isDirectory()) + return modulesUnder(dir, `${prefix}${entry.name}/`); + return /\.(ts|js|vue)$/.test(entry.name) + ? [`${prefix}${entry.name}`] + : []; + }); +const stripExtension = (name) => name.replace(/\.(ts|js|vue)$/, ''); +// `pure.files` holds globs, so a nested pure file has to be matched as one. +const globToRegExp = (pattern) => + new RegExp( + `^${pattern.replace(/\*\*\/?|\*|\{[^}]*\}|[.+^$()|[\]\\]/g, (token) => { + if (token.startsWith('**')) return '(?:.*/)?'; + if (token === '*') return '[^/]*'; + if (token.startsWith('{')) + return `(?:${token.slice(1, -1).split(',').join('|')})`; + return `\\${token}`; + })}$` + ); + +features.forEach((dir) => { + // A feature the lint config lists ahead of its directory has nothing to check. + if (!existsSync(path.join(worktree, 'src', dir))) return; + const listed = listAfter(dir).map(stripExtension); + const pure = pureFilesFor(dir).map(globToRegExp); + const covered = (module, file) => + listed.includes(module) || + listed.some( + (entry) => + entry.endsWith('/**') && + (module === entry.slice(0, -3) || module.startsWith(entry.slice(0, -2))) + ) || + pure.some((pattern) => pattern.test(file)); + modulesUnder(dir) + .map((relative) => [stripExtension(relative), `src/${dir}/${relative}`]) + .filter(([, file]) => added.has(file)) + .filter(([module, file]) => !covered(module, file)) + .forEach(([module]) => + failures.push( + `src/${dir}/${module} is neither a pure file nor in ${dir}'s upperModules, so the pure layer may import it while lint stays green.` + ) + ); +}); + +if (failures.length > 0) { + console.error('\nConventions:\n'); + failures.forEach((failure) => console.error(` ${failure}`)); + console.error( + '\nBypass additional checks for one commit with CHECKS_SKIP=1.\n' + ); + process.exitCode = 1; +} diff --git a/scripts/checks/duplication.mjs b/scripts/checks/duplication.mjs new file mode 100755 index 000000000..cd5835261 --- /dev/null +++ b/scripts/checks/duplication.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Test duplication ratchet: the staged tests may not introduce a clone that +// did not already exist at the base ref. +// +// duplication.mjs --base main compares the index with merge-base(main, HEAD). +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { git, materialize, paths, stagedContext } from './git.mjs'; +import { minCloneTokens as MIN_TOKENS } from './config.mjs'; + +const TEST = /(__tests__\/.*|\.(spec|test)|^tests\/.*)\.[cm]?[jt]sx?$/; + +const { base, baseRef, changed: entries, worktree } = stagedContext(); +const changed = entries.filter(({ file }) => TEST.test(file)); +if (changed.length === 0) process.exit(0); + +const ts = createRequire(path.join(worktree, 'package.json'))('typescript'); +const indexTests = paths(git('ls-files', '-z')).filter((f) => TEST.test(f)); +const baseTests = paths(git('ls-tree', '-rz', '--name-only', base)).filter( + (f) => TEST.test(f) +); + +const withoutImports = (file, text) => { + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false); + let result = text; + // Blank complete imports before tokenization, preserving diagnostic line numbers. + [...source.statements].reverse().forEach((statement) => { + if ( + !ts.isImportDeclaration(statement) && + !ts.isImportEqualsDeclaration(statement) + ) + return; + const start = statement.getStart(source); + const end = statement.end; + result = + result.slice(0, start) + + result.slice(start, end).replace(/[^\r\n]/g, ' ') + + result.slice(end); + }); + return result; +}; + +// jscpd reports a basename with no directory, and this repo has test files +// that share one, so each file is materialized under a flattened name that +// round-trips to its real path. +const flatten = (file) => file.replaceAll('/', '%'); +const unflatten = (name) => name.replaceAll('%', '/'); + +const jscpd = path.join(worktree, 'node_modules/.bin/jscpd'); +const run = (root, args) => + execFileSync( + jscpd, + [ + '--min-tokens', + String(MIN_TOKENS), + '--format', + 'typescript,tsx,javascript,jsx', + ...args, + '.', + ], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'] } + ); + +const tmp = mkdtempSync(path.join(tmpdir(), 'dupes-')); +try { + const candRoot = path.join(tmp, 'cand'); + const baseRoot = path.join(tmp, 'base'); + const baseline = path.join(tmp, 'baseline.json'); + const report = path.join(tmp, 'report'); + + materialize( + candRoot, + indexTests.map((f) => [flatten(f), withoutImports(f, git('show', `:${f}`))]) + ); + materialize( + baseRoot, + baseTests.map((f) => [ + flatten(f), + withoutImports(f, git('show', `${base}:${f}`)), + ]) + ); + + // Fingerprints are content-derived, so a baseline taken from the base tree + // identifies exactly the clones the staged tests add. + run(baseRoot, ['--baseline', baseline, '--update-baseline', '-r', 'silent']); + run(candRoot, ['--baseline', baseline, '-r', 'json', '-o', report]); + + const { duplicates } = JSON.parse( + readFileSync(path.join(report, 'jscpd-report.json'), 'utf8') + ); + const added = duplicates.filter((clone) => clone.isNew); + + if (added.length > 0) { + console.error( + '\nTest duplication ratchet: these clones are new since %s.\n', + baseRef + ); + added.forEach(({ firstFile, secondFile, tokens }) => { + console.error( + ` ${unflatten(firstFile.name)}:${firstFile.start}-${firstFile.end}` + + ` == ${unflatten(secondFile.name)}:${secondFile.start}-${secondFile.end}` + + ` (${tokens} tokens)` + ); + }); + console.error( + `\nA clone is ${MIN_TOKENS}+ identical tokens. Pull the shared setup into a helper the existing specs already use.` + + `\nBypass additional checks for one commit with CHECKS_SKIP=1.\n` + ); + process.exitCode = 1; + } +} finally { + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/scripts/checks/git.mjs b/scripts/checks/git.mjs new file mode 100644 index 000000000..225592ba6 --- /dev/null +++ b/scripts/checks/git.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; + +export const git = (...args) => + execFileSync('git', args, { + encoding: 'utf8', + maxBuffer: 1 << 28, + stdio: ['ignore', 'pipe', 'pipe'], + }); + +export const paths = (text) => text.split('\0').filter(Boolean); + +export const resolveCommit = (ref) => + git('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`).trim(); + +export function changedFiles(args) { + const entries = paths( + git( + 'diff', + '--name-status', + '-z', + '--find-renames', + '--diff-filter=AMRT', + ...args, + '--' + ) + ); + const changed = []; + for (let i = 0; i < entries.length; ) { + const status = entries[i++]; + const previous = entries[i++]; + const file = status.startsWith('R') ? entries[i++] : previous; + changed.push({ file, before: status === 'A' ? undefined : previous }); + } + return changed; +} + +export function stagedContext() { + const { values } = parseArgs({ options: { base: { type: 'string' } } }); + const worktree = git('rev-parse', '--show-toplevel').trim(); + process.chdir(worktree); + const baseRef = values.base ?? 'HEAD'; + const base = + baseRef === 'HEAD' + ? resolveCommit('HEAD') + : git('merge-base', resolveCommit(baseRef), 'HEAD').trim(); + const changed = changedFiles(['--cached', base]); + return { base, baseRef, changed, worktree }; +} + +export function materialize(root, entries) { + mkdirSync(root, { recursive: true }); + for (const [file, text] of entries) { + if (text === undefined) continue; + const target = path.join(root, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, text); + } +} diff --git a/src/__tests__/segmentGroupRemoval.spec.ts b/src/__tests__/segmentGroupRemoval.spec.ts new file mode 100644 index 000000000..5df19fdaa --- /dev/null +++ b/src/__tests__/segmentGroupRemoval.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { exists, hits, isTest, read, sourceFiles } from './sourceAudit'; + +// Source-level checks keep deleted group infrastructure from returning. + +const SCALAR_PROBE = 'src/components/tools/ScalarProbe.vue'; +const SEGMENTATION_REPRESENTATION = + 'src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue'; + +const DELETED = [ + 'src/store/segmentGroups.ts', + 'src/store/view-configs/segmentGroups.ts', + 'src/components/SegmentGroupControls.vue', + 'src/components/SegmentGroupOpacity.vue', +]; + +/** Production source: a member kept alive only by its own spec is still dead. */ +const production = sourceFiles(import.meta.url, 'src').filter( + (rel) => !isTest(rel) +); + +describe('the group layer is deleted', () => { + it.each(DELETED)('has no %s', (rel) => { + expect(exists(rel)).toBe(false); + }); + + it('has no production reference to the group store', () => { + expect(hits(production, /useSegmentGroupStore/)).toEqual([]); + expect(hits(production, /store\/segmentGroups'/)).toEqual([]); + }); + + it('has no production reference to the per-view group config', () => { + expect( + hits(production, /useSegmentGroupConfigStore|useGlobalSegmentGroupConfig/) + ).toEqual([]); + expect(hits(production, /view-configs\/segmentGroups'/)).toEqual([]); + }); + + it('has no per-parent artifact order left to keep in step', () => { + expect(hits(production, /artifactOrderByParent|artifactsForImage/)).toEqual( + [] + ); + }); + + it('leaves no spec asserting against the deleted module', () => { + const specs = sourceFiles(import.meta.url, 'src').filter(isTest); + expect(hits(specs, /store\/segmentGroups'|useSegmentGroupStore/)).toEqual( + [] + ); + }); +}); + +describe('the value-keyed projection has one publisher', () => { + it('reads segment names from the store projection in the probe', () => { + const source = read(SCALAR_PROBE); + expect(source).toContain('labelmapDescriptorByMask'); + // A computed label-value key would duplicate the store projection. + expect(source).not.toMatch(/\[[^\]]*labelValue[^\]]*\]\s*:/); + }); + + it('declares the outline settings once, on the segment model', () => { + // The segment model is the sole owner of outline settings. + expect(hits(production, /SegmentGroupConfig/)).toEqual([]); + expect(read(SEGMENTATION_REPRESENTATION)).toMatch(/outlineThickness/); + }); +}); diff --git a/src/__tests__/sourceAudit.ts b/src/__tests__/sourceAudit.ts new file mode 100644 index 000000000..3e8bef12b --- /dev/null +++ b/src/__tests__/sourceAudit.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Shared scaffolding for the specs that grep the tree instead of importing it. +// Every path here is repo-relative and POSIX-keyed, so a spec's own path +// constants compare equal on Windows too. + +export const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); + +const SOURCE_EXTENSIONS = ['.ts', '.js', '.vue']; +const SKIPPED_DIRS = new Set(['node_modules', 'emscripten-build', 'dist']); + +const toPosix = (rel: string) => rel.split(path.sep).join('/'); + +const relativeToRoot = (full: string) => toPosix(path.relative(repoRoot, full)); + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + return SKIPPED_DIRS.has(entry.name) ? [] : walk(full); + } + return SOURCE_EXTENSIONS.includes(path.extname(entry.name)) ? [full] : []; + }); +} + +/** + * Every source file under `dirs`, minus the calling spec: pass + * `import.meta.url` so a spec never audits its own literals. + */ +export function sourceFiles(specUrl: string, ...dirs: string[]) { + const self = relativeToRoot(fileURLToPath(specUrl)); + return dirs + .flatMap((dir) => walk(path.resolve(repoRoot, dir))) + .map(relativeToRoot) + .filter((rel) => rel !== self); +} + +export const read = (rel: string) => + fs.readFileSync(path.resolve(repoRoot, rel), 'utf-8'); + +export const exists = (rel: string) => + fs.existsSync(path.resolve(repoRoot, rel)); + +export const isTest = (rel: string) => rel.split('/').includes('__tests__'); + +/** `file:line` for every line of `files` matching `pattern`. */ +export function hits(files: string[], pattern: RegExp) { + return files.flatMap((rel) => + read(rel) + .split('\n') + .flatMap((line, index) => + pattern.test(line) ? [`${rel}:${index + 1}`] : [] + ) + ); +} diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index e1ef6ad7a..5b5904fc8 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -8,7 +8,7 @@ import { import useLoadDataStore from '@/src/store/load-data'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useLayersStore } from '@/src/store/datasets-layers'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; import { wrapInArray, nonNullable, partition } from '@/src/utils'; import { basename } from '@/src/utils/path'; import { parseUrl } from '@/src/utils/url'; @@ -90,7 +90,7 @@ function sortByDataSourceName(a: LoadableResult, b: LoadableResult) { // does not pick segmentation or layer images function findBaseImage( loadableDataSources: Array, - segmentGroupExtension: string, + segmentationExtension: string, layerExtension: string ) { const baseImages = loadableDataSources @@ -99,7 +99,7 @@ function findBaseImage( const name = getDataSourceName(importResult.dataSource); if (!name) return false; return ( - !isSegmentation(segmentGroupExtension, name) && + !isSegmentation(segmentationExtension, name) && !isSegmentation(layerExtension, name) ); }); @@ -149,7 +149,7 @@ function getStudyUID(volumeID: string) { function findBaseDataSource( succeeded: Array, - segmentGroupExtension: string, + segmentationExtension: string, layerExtension: string ) { const loadableDataSources = filterLoadableDataSources(succeeded); @@ -158,7 +158,7 @@ function findBaseDataSource( const baseImage = findBaseImage( loadableDataSources, - segmentGroupExtension, + segmentationExtension, layerExtension ); if (baseImage) return baseImage; @@ -228,18 +228,18 @@ function autoLayerByName( }); } -// Loads other DataSources as Segment Groups: +// Loads other DataSources as SegmentMask Groups: // - DICOM SEG modalities with matching StudyUIDs. // - DataSources that have a name like foo.segmentation.bar and the primary DataSource is named foo.baz function loadSegmentations( primaryDataSource: LoadableVolumeResult, succeeded: Array, - segmentGroupExtension: string + segmentationExtension: string ) { const matchingNames = filterMatchingNames( primaryDataSource, succeeded, - segmentGroupExtension + segmentationExtension ) .filter( isVolumeResult // filter out models @@ -256,10 +256,10 @@ function loadSegmentations( return modality.trim() === 'SEG'; }); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); [...otherSegVolumesInStudy, ...matchingNames].forEach((ds) => { const loadable = toDataSelection(ds); - segmentGroupStore.convertImageToLabelmap( + segmentationStore.startLabelmapConversion( loadable, toDataSelection(primaryDataSource) ); @@ -307,7 +307,7 @@ function loadDataSourcesWithOutcome( if (succeeded.length && shouldShowData) { const primaryDataSource = findBaseDataSource( succeeded, - loadDataStore.segmentGroupExtension, + loadDataStore.segmentationExtension, loadDataStore.layerExtension ); @@ -323,7 +323,7 @@ function loadDataSourcesWithOutcome( loadSegmentations( primaryDataSource, succeeded, - loadDataStore.segmentGroupExtension + loadDataStore.segmentationExtension ); } // else must be primaryDataSource.type === 'model', which are not dealt with here yet } diff --git a/src/assets/eyedropper-cursor.svg b/src/assets/eyedropper-cursor.svg new file mode 100644 index 000000000..5b7b82197 --- /dev/null +++ b/src/assets/eyedropper-cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/AnnotationsModule.vue b/src/components/AnnotationsModule.vue index ec1456493..f6e0c16b2 100644 --- a/src/components/AnnotationsModule.vue +++ b/src/components/AnnotationsModule.vue @@ -1,85 +1,34 @@ - - + diff --git a/src/components/ColorDot.vue b/src/components/ColorDot.vue deleted file mode 100644 index 267a48cb6..000000000 --- a/src/components/ColorDot.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/src/components/ControlsStripTools.vue b/src/components/ControlsStripTools.vue index 97265d719..8a9d69c26 100644 --- a/src/components/ControlsStripTools.vue +++ b/src/components/ControlsStripTools.vue @@ -78,40 +78,31 @@ v-slot:default="{ active, toggle }" :value="Tools.Rectangle" > - - - + /> - - - + /> - - - + />
@@ -146,9 +137,6 @@ import { toRef } from 'vue'; import MenuControlButton from '@/src/components/MenuControlButton.vue'; import CropControls from '@/src/components/tools/crop/CropControls.vue'; import ResetViews from '@/src/components/tools/ResetViews.vue'; -import RulerControls from '@/src/components/RulerControls.vue'; -import RectangleControls from '@/src/components/RectangleControls.vue'; -import PolygonControls from '@/src/components/PolygonControls.vue'; import WindowLevelControls from '@/src/components/tools/windowing/WindowLevelControls.vue'; import { actionToKey, @@ -166,9 +154,6 @@ export default defineComponent({ GroupableItem, CropControls, ResetViews, - RulerControls, - RectangleControls, - PolygonControls, WindowLevelControls, }, setup() { diff --git a/src/components/EditableChipList.vue b/src/components/EditableChipList.vue deleted file mode 100644 index fb48041c8..000000000 --- a/src/components/EditableChipList.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/src/components/EditableItemList.vue b/src/components/EditableItemList.vue new file mode 100644 index 000000000..4a42ba910 --- /dev/null +++ b/src/components/EditableItemList.vue @@ -0,0 +1,319 @@ + + + + + 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 113e5b2ad..b19a7073c 100644 --- a/src/components/ImageDataBrowser.vue +++ b/src/components/ImageDataBrowser.vue @@ -4,7 +4,7 @@ import ItemGroup from '@/src/components/ItemGroup.vue'; import GroupableItem from '@/src/components/GroupableItem.vue'; import ImageListCard from '@/src/components/ImageListCard.vue'; import { createVTKImageThumbnailer } from '@/src/core/thumbnailers/vtk-image'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; import { isRegularImage, type DataSelection, @@ -37,7 +37,7 @@ export default defineComponent({ const imageStore = useImageStore(); const dataStore = useDatasetStore(); const layersStore = useLayersStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); const viewSliceStore = useViewSliceStore(); const viewCameraStore = useViewCameraStore(); const imageCacheStore = useImageCacheStore(); @@ -78,6 +78,8 @@ export default defineComponent({ spacing: [...metadata.spacing].map((s) => s.toFixed(2)), layerable, layerLoading, + convertingToSegmentation: + segmentationStore.convertingLabelmaps.has(id), isLayer, layerHandler: () => { if (!layerLoading && layerable) { @@ -155,7 +157,7 @@ export default defineComponent({ function convertToLabelMap(key: string) { if (currentImageID.value) { - segmentGroupStore.convertImageToLabelmap(key, currentImageID.value); + segmentationStore.startLabelmapConversion(key, currentImageID.value); } } @@ -274,6 +276,15 @@ export default defineComponent({ @click="select" @dragstart="onDragStart(image.id, $event)" > +
mdi-alert - Add as Segment Group + Add as segmentation -import { computed, ref, reactive } from 'vue'; -import EditableChipList from '@/src/components/EditableChipList.vue'; -import { LabelsStore } from '@/src/store/tools/useLabels'; -import type { AnnotationTool } from '@/src/types/annotation-tool'; -import { Maybe } from '@/src/types'; -import ToolLabelEditor from '@/src/components/ToolLabelEditor.vue'; -import IsolatedDialog from '@/src/components/IsolatedDialog.vue'; -import { nonNullable } from '@/src/utils'; -import { NO_NAME } from '@/src/constants'; - -const props = defineProps<{ - labelsStore: LabelsStore>; -}>(); - -const labels = computed(() => - Object.entries(props.labelsStore.labels).map(([id, label]) => ({ - id, - name: label.labelName ?? NO_NAME, - color: label.color, - })) -); - -const selectedLabel = computed({ - get: () => props.labelsStore.activeLabel, - set: (id: string | undefined) => { - if (id != null) props.labelsStore.setActiveLabel(id); - }, -}); - -// --- editing state --- // - -type LabelID = string; -const editingLabelID = ref>(undefined); -const editDialog = ref(false); -const editState = reactive({ - labelName: '', - strokeWidth: 1, - color: '', -}); - -const editingLabel = computed(() => { - if (!editingLabelID.value) return null; - return props.labelsStore.labels[editingLabelID.value]; -}); - -const invalidNames = computed(() => { - const names = new Set( - Object.values(props.labelsStore.labels) - .map(({ labelName }) => labelName) - .filter(nonNullable) - ); - const currentName = editingLabel.value?.labelName; - if (currentName) names.delete(currentName); // allow current name - return names; -}); - -const makeUniqueName = (name: string) => { - const existingNames = new Set( - Object.values(props.labelsStore.labels).map((label) => label.labelName) - ); - let uniqueName = name; - let i = 1; - while (existingNames.has(uniqueName)) { - uniqueName = `${name} (${i})`; - i++; - } - return uniqueName; -}; - -const createLabel = () => { - const labelName = makeUniqueName('New Label'); - editingLabelID.value = props.labelsStore.addLabel({ labelName }); -}; - -function startEditing(label: LabelID) { - editDialog.value = true; - editingLabelID.value = label; - if (editingLabel.value) { - editState.labelName = editingLabel.value.labelName ?? ''; - editState.strokeWidth = editingLabel.value.strokeWidth ?? 0; - editState.color = editingLabel.value.color ?? ''; - } -} - -function stopEditing(commit: boolean) { - if (editingLabelID.value && commit) { - props.labelsStore.updateLabel(editingLabelID.value, editState); - } - editDialog.value = false; - editingLabelID.value = null; -} - -function deleteEditingLabel() { - if (editingLabelID.value) { - props.labelsStore.deleteLabel(editingLabelID.value); - } - stopEditing(false); -} - - - - - diff --git a/src/components/LabelEditor.vue b/src/components/LabelEditor.vue index b2535f29d..2c8fb22e8 100644 --- a/src/components/LabelEditor.vue +++ b/src/components/LabelEditor.vue @@ -2,14 +2,18 @@ import { computed, toRefs } from 'vue'; const emit = defineEmits(['done', 'cancel', 'delete', 'update:color']); -const props = defineProps<{ color: string; valid: boolean }>(); +const props = defineProps<{ + color: string; + valid: boolean; + disabledReason?: string; +}>(); const { color, valid } = toRefs(props); const doneDisabled = computed(() => { - return !valid.value; + return !valid.value || !!props.disabledReason; }); const done = () => { - emit('done'); + if (!doneDisabled.value) emit('done'); }; const cancel = () => { @@ -17,6 +21,7 @@ const cancel = () => { }; const onDelete = () => { + if (props.disabledReason) return; emit('delete'); emit('done'); }; @@ -30,22 +35,49 @@ const onDelete = () => {
- - Delete - + + + Delete + + {{ disabledReason }} + Cancel - - Done - + + Done + + {{ disabledReason || 'Choose a unique name' }} +
-import { useRulerStore } from '@/src/store/tools/rulers'; -import { AnnotationTool } from '../types/annotation-tool'; - -defineProps<{ - tool: AnnotationTool & { axis: string }; -}>(); - -const toolStore = useRulerStore(); - - - diff --git a/src/components/MeasurementToolDetails.vue b/src/components/MeasurementToolDetails.vue deleted file mode 100644 index b7501daaf..000000000 --- a/src/components/MeasurementToolDetails.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/components/MeasurementsToolList.vue b/src/components/MeasurementsToolList.vue index 794071cfa..ca05769da 100644 --- a/src/components/MeasurementsToolList.vue +++ b/src/components/MeasurementsToolList.vue @@ -1,237 +1,247 @@ - - diff --git a/src/components/SegmentGroupOpacity.vue b/src/components/SegmentGroupOpacity.vue deleted file mode 100644 index 8bb4235f5..000000000 --- a/src/components/SegmentGroupOpacity.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - diff --git a/src/components/SegmentList.vue b/src/components/SegmentList.vue deleted file mode 100644 index d277bf0df..000000000 --- a/src/components/SegmentList.vue +++ /dev/null @@ -1,284 +0,0 @@ - - - - - diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index ebc88d5da..4bee7f8f1 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -97,10 +97,10 @@ :axis="viewAxis" > @@ -154,7 +154,7 @@ @@ -171,8 +171,8 @@ import VtkSliceView from '@/src/components/vtk/VtkSliceView.vue'; import { VtkViewApi } from '@/src/types/vtk-types'; import { Tools } from '@/src/store/tools/types'; import VtkBaseSliceRepresentation from '@/src/components/vtk/VtkBaseSliceRepresentation.vue'; -import VtkSegmentationSliceRepresentation from '@/src/components/vtk/VtkSegmentationSliceRepresentation.vue'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import VtkSegmentationSliceRepresentation from '@/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue'; +import { useSegmentationStore } from '@/src/segmentation/store'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; import CropTool from '@/src/components/tools/crop/CropTool.vue'; @@ -265,10 +265,10 @@ onVTKEvent(currentImageData, 'onModified', () => { vtkView.value?.requestRender(); }); -const segmentations = computed(() => { +// One actor per segment, in `segmentation.order`. +const segmentLayers = computed(() => { if (!currentImageID.value) return []; - const store = useSegmentGroupStore(); - return store.orderByParent[currentImageID.value]; + return useSegmentationStore().maskLayersForImage(currentImageID.value); }); // --- selection points --- // diff --git a/src/components/ToolControls.vue b/src/components/ToolControls.vue index 01aac8195..038a87985 100644 --- a/src/components/ToolControls.vue +++ b/src/components/ToolControls.vue @@ -1,94 +1,7 @@ - - diff --git a/src/components/ToolLabelEditor.vue b/src/components/ToolLabelEditor.vue deleted file mode 100644 index cfa1ebb71..000000000 --- a/src/components/ToolLabelEditor.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - diff --git a/src/components/__tests__/LabelEditor.spec.ts b/src/components/__tests__/LabelEditor.spec.ts new file mode 100644 index 000000000..18d253422 --- /dev/null +++ b/src/components/__tests__/LabelEditor.spec.ts @@ -0,0 +1,58 @@ +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import LabelEditor from '@/src/components/LabelEditor.vue'; + +const Button = defineComponent({ + props: ['disabled'], + template: '', +}); + +const Shell = { template: '
' }; + +const mountEditor = () => + mount(LabelEditor, { + props: { color: '#ff0000', valid: true }, + global: { + stubs: { + VCard: Shell, + VCardItem: Shell, + VCardActions: Shell, + VBtn: Button, + VTooltip: Shell, + VSpacer: true, + VColorPicker: true, + }, + }, + }); + +describe('editor actions while a segment becomes locked', () => { + it.each(['Delete', 'Done'])( + 'disables and guards %s until unlocking', + async (action) => { + const wrapper = mountEditor(); + const button = wrapper + .findAllComponents(Button) + .find((candidate) => candidate.text() === action)!; + await wrapper.setProps({ + disabledReason: 'Unlock this segment to edit or delete it', + }); + expect(button.attributes('disabled')).toBeDefined(); + expect(button.element.parentElement?.textContent).toContain( + 'Unlock this segment' + ); + // A stale UI event must obey the same eligibility as the visible button. + button.vm.$emit('click'); + expect(wrapper.emitted('done')).toBeUndefined(); + expect(wrapper.emitted('delete')).toBeUndefined(); + + await wrapper.setProps({ disabledReason: undefined }); + expect(button.attributes('disabled')).toBeUndefined(); + await button.trigger('click'); + expect( + wrapper.emitted(action === 'Delete' ? 'delete' : 'done') + ).toHaveLength(1); + wrapper.unmount(); + } + ); +}); diff --git a/src/components/__tests__/MeasurementDetails.spec.ts b/src/components/__tests__/MeasurementDetails.spec.ts deleted file mode 100644 index 6c7ba0736..000000000 --- a/src/components/__tests__/MeasurementDetails.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { createPinia, setActivePinia } from 'pinia'; -import { beforeEach, describe, expect, it } from 'vitest'; -import MeasurementToolDetails from '@/src/components/MeasurementToolDetails.vue'; -import MeasurementRulerDetails from '@/src/components/MeasurementRulerDetails.vue'; -import { useRulerStore } from '@/src/store/tools/rulers'; -import { ToolID } from '@/src/types/annotation-tool'; - -beforeEach(() => { - setActivePinia(createPinia()); -}); - -const stubs = { - 'v-row': { template: '
' }, - 'v-col': { template: '
' }, -}; - -const baseTool = { - id: 'tool-1' as ToolID, - imageID: 'img-1', - frameOfReference: { - planeOrigin: [0, 0, 0] as [number, number, number], - planeNormal: [0, 0, 1] as [number, number, number], - }, - color: '#fff', - name: 'Tool', - axis: 'Axial', -}; - -describe('MeasurementToolDetails', () => { - it('shows slice number for a volume annotation', () => { - const wrapper = mount(MeasurementToolDetails, { - props: { tool: { ...baseTool, slice: 4 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Slice: 5'); - expect(wrapper.text()).not.toContain('Frame:'); - }); - - it('shows frame number for a cine annotation', () => { - const wrapper = mount(MeasurementToolDetails, { - props: { tool: { ...baseTool, slice: 0, frame: 7 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Frame: 8'); - expect(wrapper.text()).not.toContain('Slice:'); - }); -}); - -describe('MeasurementRulerDetails', () => { - // The component reads the length off the ruler store, so the ruler has to - // exist there: a 3-4-5 triangle gives a length of 5.00mm. - const seatRuler = () => - useRulerStore().addRuler({ - firstPoint: [0, 0, 0], - secondPoint: [3, 4, 0], - }); - - it('shows slice number for a volume ruler', () => { - const wrapper = mount(MeasurementRulerDetails, { - props: { tool: { ...baseTool, id: seatRuler(), slice: 9 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Slice: 10'); - expect(wrapper.text()).toContain('5.00mm'); - expect(wrapper.text()).not.toContain('Frame:'); - }); - - it('shows frame number for a cine ruler', () => { - const wrapper = mount(MeasurementRulerDetails, { - props: { tool: { ...baseTool, id: seatRuler(), slice: 0, frame: 2 } }, - global: { stubs }, - }); - expect(wrapper.text()).toContain('Frame: 3'); - expect(wrapper.text()).not.toContain('Slice:'); - }); -}); diff --git a/src/components/__tests__/PatientStudyVolumeBrowser.spec.ts b/src/components/__tests__/PatientStudyVolumeBrowser.spec.ts new file mode 100644 index 000000000..df45e9c1e --- /dev/null +++ b/src/components/__tests__/PatientStudyVolumeBrowser.spec.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { defineComponent, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import PatientStudyVolumeBrowser from '@/src/components/PatientStudyVolumeBrowser.vue'; +import { seatVolume } from '@/src/store/__tests__/datasetFixtures'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import type { ProgressiveImage } from '@/src/core/progressiveImage'; + +const SlotStub = defineComponent({ + template: '
', +}); + +const mountBrowser = () => + mount(PatientStudyVolumeBrowser, { + props: { volumeKeys: ['seg-volume'] }, + global: { + stubs: { + GroupableItem: { + template: '
', + }, + PersistentOverlay: { + props: ['disabled'], + template: '
', + }, + VImg: { + template: '
', + }, + VProgressCircular: { template: '
' }, + VContainer: SlotStub, + VRow: SlotStub, + VCol: SlotStub, + VCard: SlotStub, + VCardText: SlotStub, + VCheckbox: true, + VBtn: true, + VMenu: true, + VList: true, + VListItem: true, + VIcon: true, + VTooltip: true, + VSpacer: true, + }, + }, + }); + +describe('DICOM segmentation conversion progress', () => { + beforeEach(() => { + setActivePinia(createPinia()); + seatVolume('seg-volume', { + Modality: 'SEG', + SeriesDescription: 'TotalSegmentator segmentation', + }); + useImageCacheStore().imageById['seg-volume'] = { + getThumbnail: () => Promise.resolve(null), + } as ProgressiveImage; + }); + + it('covers the source thumbnail while it is becoming a segmentation', async () => { + const segmentations = useSegmentationStore(); + segmentations.convertingLabelmaps.add('seg-volume'); + const wrapper = mountBrowser(); + await nextTick(); + + const progress = wrapper.find( + '[data-testid="segmentation-conversion-progress"]' + ); + expect(progress.exists()).toBe(true); + expect(progress.text()).toContain('Adding segmentation'); + expect(wrapper.findAll('.progress')).toHaveLength(1); + + segmentations.convertingLabelmaps.delete('seg-volume'); + await nextTick(); + expect( + wrapper.find('[data-testid="segmentation-conversion-progress"]').exists() + ).toBe(false); + }); +}); diff --git a/src/components/processes.ts b/src/components/processes.ts index d16aff121..353f13997 100644 --- a/src/components/processes.ts +++ b/src/components/processes.ts @@ -2,14 +2,14 @@ import type { Component } from 'vue'; import { ProcessType, type ProcessAlgorithm, -} from '@/src/store/tools/paintProcess'; +} from '@/src/segmentation/editing/paintProcess'; import { useFillHolesStore, FillHolesSegmentScope, -} from '@/src/store/tools/fillHoles'; -import { useFillBetweenStore } from '@/src/store/tools/fillBetween'; -import { useGaussianSmoothStore } from '@/src/store/tools/gaussianSmooth'; -import FillHolesParameterControls from './FillHolesParameterControls.vue'; +} from '@/src/segmentation/editing/fillHoles'; +import { useFillBetweenStore } from '@/src/segmentation/editing/fillBetween'; +import { useGaussianSmoothStore } from '@/src/segmentation/editing/gaussianSmooth'; +import FillHolesParameterControls from '@/src/segmentation/components/FillHolesParameterControls.vue'; import FillBetweenParameterControls from './FillBetweenParameterControls.vue'; import GaussianSmoothParameterControls from './GaussianSmoothParameterControls.vue'; diff --git a/src/components/styles/annotation-panels.css b/src/components/styles/annotation-panels.css new file mode 100644 index 000000000..59b3d439c --- /dev/null +++ b/src/components/styles/annotation-panels.css @@ -0,0 +1,21 @@ +.annotation-panels { + width: 100%; +} + +.annotation-panels .v-expansion-panel-title { + min-height: 48px; + padding-inline: 16px; +} + +.annotation-panels .annotation-panel-icon { + flex: 0 0 auto; + margin-inline-end: 12px; +} + +.annotation-panels .v-expansion-panel-text__wrapper { + padding: 8px 12px 12px; +} + +.annotation-panels .v-expansion-panel::after { + border-top: 0; +} diff --git a/src/components/tools/AnnotationContextMenu.vue b/src/components/tools/AnnotationContextMenu.vue index 92afe0925..d89a4d695 100644 --- a/src/components/tools/AnnotationContextMenu.vue +++ b/src/components/tools/AnnotationContextMenu.vue @@ -6,6 +6,7 @@ import { WidgetAction, } from '@/src/vtk/ToolWidgetUtils/types'; import { ToolID } from '@/src/types/annotation-tool'; +import { useToolAppearance } from '@/src/composables/annotationTool'; const props = defineProps<{ toolStore: AnnotationToolStore; @@ -35,6 +36,8 @@ const tool = computed(() => { return props.toolStore.toolByID[contextMenu.forToolID]; }); +const appearance = useToolAppearance(props.toolStore, () => tool.value); + const deleteToolFromContextMenu = () => { props.toolStore.removeTool(contextMenu.forToolID); }; @@ -61,11 +64,11 @@ const hideToolFromContextMenu = () => { - {{ tool.labelName }} + {{ appearance.name }} diff --git a/src/components/tools/AnnotationInfo.vue b/src/components/tools/AnnotationInfo.vue index 38e90b594..d67205a6c 100644 --- a/src/components/tools/AnnotationInfo.vue +++ b/src/components/tools/AnnotationInfo.vue @@ -30,7 +30,8 @@ const metadata = computed(() => { const label = computed(() => { if (!props.info.visible) return ''; - return props.toolStore.toolByID[props.info.toolID].labelName; + const { segmentId } = props.toolStore.toolByID[props.info.toolID]; + return props.toolStore.segments.appearanceOf(segmentId).name; }); const tooltip = ref(); diff --git a/src/components/tools/ScalarProbe.vue b/src/components/tools/ScalarProbe.vue index a3e317840..3752d7a19 100644 --- a/src/components/tools/ScalarProbe.vue +++ b/src/components/tools/ScalarProbe.vue @@ -8,7 +8,8 @@ import { VtkViewContext } from '@/src/components/vtk/context'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import vtkPointPicker from '@kitware/vtk.js/Rendering/Core/PointPicker'; import { useSliceRepresentation } from '@/src/core/vtk/useSliceRepresentation'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useProbeStore } from '@/src/store/probe'; import { useImageCacheStore } from '@/src/store/image-cache'; import { NO_NAME } from '@/src/constants'; @@ -18,10 +19,10 @@ type SliceRepresentationType = ReturnType; const props = defineProps<{ baseRep: SliceRepresentationType; layerReps: SliceRepresentationType[]; - segmentGroupsReps: SliceRepresentationType[]; + segmentReps: SliceRepresentationType[]; }>(); -const { baseRep, layerReps, segmentGroupsReps } = toRefs(props); +const { baseRep, layerReps, segmentReps } = toRefs(props); const view = inject(VtkViewContext); if (!view) throw new Error('No VtkView'); @@ -32,7 +33,8 @@ const { currentLayers, } = useCurrentImage(); const imageCacheStore = useImageCacheStore(); -const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); +const { segments: segments } = useSegmentStore(); const probeStore = useProbeStore(); // Helper functions to build a unified sample set @@ -65,22 +67,29 @@ const getLayers = () => }) .filter(Boolean); +// Paired positionally with the slice view's segment actors, which come off the +// same ordered list. const getSegments = () => { if (!currentImageID.value) return []; - const parentGroups = segmentGroupStore.orderByParent[currentImageID.value]; - if (!parentGroups) return []; - return segmentGroupsReps.value + const layers = segmentationStore.maskLayersForImage(currentImageID.value); + return segmentReps.value .map((rep, index) => { - const groupId = parentGroups[index]; - if (!groupId) return null; - const meta = segmentGroupStore.metadataByID[groupId]; + const layer = layers[index]; + if (!layer) return null; + const segment = segmentationStore.getMask(layer.maskId); + const voxels = segmentationStore.findMaskVoxels(layer.maskId); + if (!voxels.exists()) return null; + const descriptor = + segmentationStore.labelmapDescriptorByMask[layer.maskId]; return { - type: 'segmentGroup', - id: groupId, - name: meta.name, + type: 'segment', + id: layer.maskId, + name: segments.appearanceOf(segment.segmentId).name, rep, - segments: meta.segments, - image: segmentGroupStore.dataIndex[groupId], + nameByLabelValue: descriptor + ? { [descriptor.value]: descriptor.name } + : {}, + image: voxels.image(), }; }) .filter(Boolean); @@ -143,11 +152,14 @@ const getImageSamples = (x: number, y: number) => { const scalars = scalarData.getTuple(index) as number[]; const baseInfo = { id: item.id, name: item.name }; - if (item.type === 'segmentGroup') { + if (item.type === 'segment') { + // A mask's bounding box can contain empty voxels from other segments. + if (scalars.every((value) => value === 0)) return null; + return { ...baseInfo, displayValues: scalars.map( - (v) => item.segments.byValue[v]?.name || 'Background' + (v) => item.nameByLabelValue[v] || 'Background' ), }; } diff --git a/src/components/tools/SelectTool.vue b/src/components/tools/SelectTool.vue index 8f9ebd4ef..d7418741f 100644 --- a/src/components/tools/SelectTool.vue +++ b/src/components/tools/SelectTool.vue @@ -17,8 +17,6 @@ if (!view) throw new Error('No VtkView'); const selectionStore = useToolSelectionStore(); const toolStore = useToolStore(); -const PLACING_TOOLS = [Tools.Ruler, Tools.Rectangle, Tools.Polygon]; - const isAnnotationWidgetState = ( widgetState: unknown ): widgetState is vtkAnnotationWidgetState => { @@ -34,10 +32,9 @@ onVTKEvent( view.interactor, 'onLeftButtonPress', async (event: any) => { - if (PLACING_TOOLS.includes(toolStore.currentTool)) { - // avoid bugs when starting a placing tool on an existing tool and right clicking and deleting existing tools - return; - } + // Annotation picking belongs to Select. Drawing and navigation gestures + // must pass through existing vector shapes without changing selection. + if (toolStore.currentTool !== Tools.Select) return; const withModifiers = !!(event.shiftKey || event.controlKey); // Pick where the button went down. The widget manager's standing pick is diff --git a/src/components/tools/__tests__/ScalarProbe.spec.ts b/src/components/tools/__tests__/ScalarProbe.spec.ts new file mode 100644 index 000000000..f04e595c4 --- /dev/null +++ b/src/components/tools/__tests__/ScalarProbe.spec.ts @@ -0,0 +1,150 @@ +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import ScalarProbe from '@/src/components/tools/ScalarProbe.vue'; +import { VtkViewContext } from '@/src/components/vtk/context'; +import { useProbeStore } from '@/src/store/probe'; + +import * as currentImage from '@/src/composables/useCurrentImage'; +import * as vtkEvent from '@/src/composables/onVTKEvent'; +import vtkPointPicker from '@kitware/vtk.js/Rendering/Core/PointPicker'; +import * as imageCache from '@/src/store/image-cache'; +import * as segments from '@/src/segmentation/segments'; +import * as segmentations from '@/src/segmentation/store'; + +const state = { + current: {} as ReturnType, + masks: [] as { id: string; image: vtkImageData }[], + events: {} as Record void>, +}; + +function image(values: number[], components = 1) { + const result = vtkImageData.newInstance(); + result.setDimensions(3, 1, 1); + result.getPointData().setScalars( + vtkDataArray.newInstance({ + values: new Float32Array(values), + numberOfComponents: components, + }) + ); + return result; +} + +function probe() { + const rep = {} as InstanceType['$props']['baseRep']; + const wrapper = mount(ScalarProbe, { + props: { + baseRep: rep, + layerReps: [rep], + segmentReps: state.masks.map(() => rep), + }, + global: { + provide: { [VtkViewContext as symbol]: { renderer: {}, interactor: {} } }, + }, + }); + state.events.onMouseMove({ position: { x: 10, y: 20 } }); + const result = useProbeStore().probeData; + wrapper.unmount(); + return result; +} + +describe('ScalarProbe segment samples', () => { + afterEach(() => vi.restoreAllMocks()); + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + state.current = { + currentImageID: ref('ct'), + currentImageData: ref(image([-100, 42, 100])), + currentImageMetadata: ref({ name: 'CT' }), + currentLayers: ref([{ id: 'overlay', selection: 'overlay' }]), + } as ReturnType; + state.masks = []; + state.events = {}; + vi.spyOn(currentImage, 'useCurrentImage').mockImplementation( + () => state.current + ); + vi.spyOn(vtkEvent, 'onVTKEvent').mockImplementation( + (_target, name, callback) => { + state.events[name] = callback; + return { stop: () => {} }; + } + ); + const picker = { ...vtkPointPicker.newInstance() }; + vi.spyOn(picker, 'pick').mockImplementation(() => {}); + vi.spyOn(picker, 'getActors').mockReturnValue([ + {} as ReturnType[number], + ]); + vi.spyOn(picker, 'getPointIJK').mockReturnValue([1, 0, 0]); + vi.spyOn(vtkPointPicker, 'newInstance').mockReturnValue(picker); + const cache = imageCache.useImageCacheStore(); + vi.spyOn(cache, 'getImageMetadata').mockReturnValue({ + name: 'Overlay', + } as ReturnType); + vi.spyOn(cache, 'getVtkImageData').mockReturnValue(image([0, 0, 0])); + const registry = segments.useSegmentStore(); + vi.spyOn(registry.segments, 'appearanceOf').mockImplementation( + (id) => + ({ name: id }) as ReturnType + ); + const store = segmentations.useSegmentationStore(); + vi.spyOn(store, 'maskLayersForImage').mockImplementation(() => + state.masks.map(({ id }) => ({ maskId: id })) + ); + vi.spyOn(store, 'getMask').mockImplementation( + (id) => ({ segmentId: id }) as ReturnType + ); + vi.spyOn(store, 'findMaskVoxels').mockImplementation( + (id) => + ({ + exists: () => true, + image: () => state.masks.find((mask) => mask.id === id)!.image, + }) as ReturnType + ); + vi.spyOn(store, 'labelmapDescriptorByMask', 'get').mockImplementation(() => + Object.fromEntries( + state.masks.map(({ id }) => [ + id, + { value: 1, name: id, color: [255, 0, 0, 255], visible: true }, + ]) + ) + ); + }); + + it('omits empty mask voxels while retaining the occupied segment, CT, position, and zero image layer', () => { + state.masks = [ + { id: 'Liver', image: image([0, 1, 0]) }, + { id: 'Kidney', image: image([1, 0, 0]) }, + { id: 'Spleen', image: image([0, 0, 1]) }, + ]; + const result = probe(); + expect(Array.from(result!.pos)).toEqual([1, 0, 0]); + expect(result!.samples).toEqual([ + { id: 'Liver', name: 'Liver', displayValues: ['Liver'] }, + { id: 'overlay', name: 'Overlay', displayValues: [0] }, + { id: 'ct', name: 'CT', displayValues: [42] }, + ]); + }); + + it('retains genuinely overlapping segments and masks with any occupied component', () => { + state.masks = [ + { id: 'First', image: image([0, 1, 0]) }, + { id: 'Second', image: image([0, 0, 0, 1, 0, 0], 2) }, + { id: 'Empty', image: image([1, 0, 0, 0, 0, 1], 2) }, + ]; + expect(probe()!.samples.slice(0, 2)).toEqual([ + { id: 'First', name: 'First', displayValues: ['First'] }, + { id: 'Second', name: 'Second', displayValues: ['Background', 'Second'] }, + ]); + expect(probe()!.samples.map(({ id }) => id)).toEqual([ + 'First', + 'Second', + 'overlay', + 'ct', + ]); + }); +}); diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index b870dfed1..ddefb1a29 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -8,6 +8,7 @@ import { toRefs, watchEffect, inject, + ref, } from 'vue'; import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; import { vec3 } from 'gl-matrix'; @@ -15,14 +16,15 @@ import { getLPSAxisFromDir } from '@/src/utils/lps'; import { useImage } from '@/src/composables/useCurrentImage'; import { updatePlaneManipulatorFor2DView } from '@/src/utils/manipulators'; import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import { vtkPaintViewWidget } from '@/src/vtk/PaintWidget'; import { LPSAxisDir } from '@/src/types/lps'; -import { getLPSDirections } from '@/src/utils/lps'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { useSliceInfo } from '@/src/composables/useSliceInfo'; import { VtkViewContext } from '@/src/components/vtk/context'; import { Maybe } from '@/src/types'; +import { PaintMode } from '@/src/core/tools/paint'; +import { usePaintInteractionMode } from '@/src/segmentation/composables/usePaintInteractionMode'; +import eyedropperCursor from '@/src/assets/eyedropper-cursor.svg?url'; import { useActionHeld } from '@/src/composables/useKeyboardShortcuts'; export default defineComponent({ @@ -48,7 +50,10 @@ export default defineComponent({ const slice = computed(() => sliceInfo.value?.slice); const paintStore = usePaintToolStore(); - const segmentGroupStore = useSegmentGroupStore(); + const interactionMode = usePaintInteractionMode(); + const sampling = computed( + () => interactionMode.value === PaintMode.Eyedropper + ); const widgetFactory = paintStore.getWidgetFactory(); const widgetState = widgetFactory.getWidgetState(); @@ -58,46 +63,39 @@ export default defineComponent({ () => imageMetadata.value.lpsOrientation[viewAxis.value] ); - // Get the active labelmap for coordinate transforms - const activeLabelmap = computed(() => { - const groupId = paintStore.activeSegmentGroupID; - if (!groupId) return null; - return segmentGroupStore.dataIndex[groupId] ?? null; - }); - const widget = view.widgetManager.addWidget( widgetFactory ) as vtkPaintViewWidget; + widget.setPickable(false); // --- widget representation config --- // + // Every mask uses the parent voxel grid. Selection and mask growth do not + // change the brush's world-space footprint. watchEffect(() => { - if (!widget) return; - - const labelmap = activeLabelmap.value; - if (labelmap) { - // Use labelmap's transforms so brush preview matches where paint appears - const labelmapLps = getLPSDirections(labelmap.getDirection()); - const slicingIndex = labelmapLps[viewAxis.value]; - widget.setSlicingIndex(slicingIndex); - widget.setIndexToWorld(labelmap.getIndexToWorld()); - widget.setWorldToIndex(labelmap.getWorldToIndex()); - } else { - // Fall back to parent image transforms - const metadata = imageMetadata.value; - const slicingIndex = metadata.lpsOrientation[viewAxis.value]; - widget.setSlicingIndex(slicingIndex); - widget.setIndexToWorld(metadata.indexToWorld); - widget.setWorldToIndex(metadata.worldToIndex); - } + const metadata = imageMetadata.value; + widget.setSlicingIndex(metadata.lpsOrientation[viewAxis.value]); + widget.setIndexToWorld(metadata.indexToWorld); + widget.setWorldToIndex(metadata.worldToIndex); }); + // Brush movement changes shared state, but only the view displaying the + // preview needs to redraw. Mask edits request renders independently. + onVTKEvent(widgetState, 'onModified', () => { + if (widget.getVisibility()) view.requestRender(); + }); + onVTKEvent(widget, 'onModified', () => view.requestRender()); + // --- interaction --- // - onVTKEvent(widget, 'onStartInteractionEvent', () => { + onVTKEvent(widget, 'onStartInteractionEvent', (event) => { if (!imageId.value) return; - paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); const origin = widgetState.getBrush().getOrigin()!; + if (event?.sampling) { + paintStore.selectSegmentAt(vec3.clone(origin), imageId.value); + return; + } + paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); paintStore.startStroke( vec3.clone(origin), viewAxisIndex.value, @@ -144,26 +142,42 @@ export default defineComponent({ // --- visibility --- // let checkIfPointerInView = false; + const pointerInView = ref(false); + const cursorStyles = view.widgetManager.getCursorStyles(); + watchEffect(() => { + widget.setSampling(sampling.value); + widget.setVisibility(pointerInView.value && !sampling.value); + const cursor = sampling.value + ? `url("${eyedropperCursor}") 2 22, crosshair` + : cursorStyles.default; + view.widgetManager.setCursorStyles( + sampling.value + ? { ...cursorStyles, default: cursor, hover: cursor } + : cursorStyles + ); + view.renderWindowView.set({ cursor }); + }); // Turn on widget visibility and update stencil if mouse starts within view - onVTKEvent(view.interactor, 'onMouseMove', () => { + const showPreviewOnFirstMove = () => { if (!checkIfPointerInView) return; checkIfPointerInView = false; - widget.setVisibility(true); + pointerInView.value = true; if (imageId.value) { paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); } - }); + }; + onVTKEvent(view.interactor, 'onMouseMove', showPreviewOnFirstMove); onVTKEvent(view.interactor, 'onMouseEnter', () => { if (imageId.value) { paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); } - widget.setVisibility(true); + pointerInView.value = true; }); onVTKEvent(view.interactor, 'onMouseLeave', () => { - widget.setVisibility(false); + pointerInView.value = false; }); watchEffect(() => { @@ -182,8 +196,8 @@ export default defineComponent({ }; onMounted(() => { - view.widgetManager.renderWidgets(); view.widgetManager.grabFocus(widget); + view.widgetManager.renderWidgets(); widget.setVisibility(false); checkIfPointerInView = true; view.renderWindowView @@ -192,6 +206,8 @@ export default defineComponent({ }); onUnmounted(() => { + view.widgetManager.setCursorStyles(cursorStyles); + view.renderWindowView.set({ cursor: cursorStyles.default }); view.widgetManager.removeWidget(widgetFactory); view.renderWindowView .getContainer() diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index 89c19634a..fc5371984 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -10,6 +10,7 @@ :view-id="viewId" :view-direction="viewDirection" @contextmenu="openContextMenu(tool.id, $event)" + @placing="onPlacementStarted" @placed="onToolPlaced" @widgetHover="onHover(tool.id, $event)" /> @@ -19,48 +20,14 @@ :tool-store="activeToolStore" v-slot="{ context }" > - + - Rasterize as... - - - - - No segment group selected - - + Rasterize import { computed, defineComponent, onUnmounted, PropType, toRefs } from 'vue'; -import { storeToRefs } from 'pinia'; -import { useImage } from '@/src/composables/useCurrentImage'; import { useToolStore } from '@/src/store/tools'; import { Tools } from '@/src/store/tools/types'; import { getLPSAxisFromDir } from '@/src/utils/lps'; @@ -106,54 +71,14 @@ import { Maybe } from '@/src/types'; import { useViewLocator } from '@/src/composables/useViewLocator'; import { locatorPatch } from '@/src/core/annotations/locator'; import { watchImmediate } from '@vueuse/core'; -import { fillPoly } from '@thi.ng/rasterize'; -import type { IGrid2D } from '@thi.ng/api'; -import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import type { Vector2, Vector3 } from '@kitware/vtk.js/types'; -import { containsPoint } from '@kitware/vtk.js/Common/DataModel/BoundingBox'; -import { convertSliceIndex } from '@/src/utils/imageSpace'; -import { getLPSDirections } from '@/src/utils/lps'; import { type ToolID } from '@/src/types/annotation-tool'; import PolygonWidget2D from '@/src/components/tools/polygon/PolygonWidget2D.vue'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import ColorDot from '@/src/components/ColorDot.vue'; -import { SegmentMask } from '@/src/types/segment'; +import { rasterizePolygon } from '@/src/segmentation/editing/rasterizePolygon'; import { isCineImage } from '@/src/core/cine/isCineImage'; const useActiveToolStore = usePolygonStore; const toolType = Tools.Polygon; -function createGridAccessor( - image: vtkImageData, - slice: number, - axisIdx: 0 | 1 | 2 // i/j/k -): IGrid2D { - const axisDims = image.getDimensions(); - axisDims.splice(axisIdx, 1); - const extent = image.getExtent(); - const pixelData = image.getPointData().getScalars(); - const convertTo3D = (a: number, b: number) => { - const point = [a, b]; - point.splice(axisIdx, 0, slice); - return point as Vector3; - }; - - return { - size: axisDims, - setAtUnsafe(d0: number, d1: number, value: number): boolean { - const ijk = convertTo3D(d0, d1); - if (containsPoint(extent, ...ijk)) { - const offset = image.computeOffsetIndex(ijk); - // XXX assumes single-component image - pixelData.setTuple(offset, [value]); - return true; - } - return false; - }, - } as unknown as IGrid2D; -} - export default defineComponent({ name: 'PolygonTool', props: { @@ -171,17 +96,15 @@ export default defineComponent({ PolygonWidget2D, AnnotationContextMenu, AnnotationInfo, - ColorDot, }, setup(props) { const { viewDirection, imageId, viewId } = toRefs(props); const toolStore = useToolStore(); const activeToolStore = useActiveToolStore(); - const { activeLabel } = storeToRefs(activeToolStore); + const { selectedSegmentId } = activeToolStore.segments; const { locator, frame, slice } = useViewLocator(viewId, imageId); - const { metadata: imageMetadata } = useImage(imageId); const isToolActive = computed(() => toolStore.currentTool === toolType); const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value)); @@ -194,8 +117,7 @@ export default defineComponent({ return { imageID: imageId.value, ...locatorPatch(locator.value), - label: activeLabel.value, - ...(activeLabel.value && activeToolStore.labels[activeLabel.value]), + segmentId: selectedSegmentId.value ?? '', }; }) ); @@ -213,6 +135,8 @@ export default defineComponent({ const mergeKey = useActionHeld('mergeNewPolygon'); + // The annotation delineates a type from the first point down, so it is + // drawn in that type's color rather than changing color once placed. const onToolPlaced = () => { if (imageId.value) { const newToolId = placingTool.id.value; @@ -267,18 +191,9 @@ export default defineComponent({ () => activeToolStore.mergeableTools.length >= 1 ); - const segmentGroupStore = useSegmentGroupStore(); - const paintStore = usePaintToolStore(); const isCurrentImageCine = computed(() => isCineImage(imageId.value)); - const currentSegmentGroup = computed(() => { - if (isCurrentImageCine.value) return null; - if (!imageId.value) return null; - const groups = segmentGroupStore.orderByParent[imageId.value]; - if (!groups?.length) return null; - return segmentGroupStore.metadataByID[groups[0]] ?? null; - }); - function rasterize(toolId: ToolID, segment: SegmentMask) { + function rasterize(toolId: ToolID) { if (!imageId.value) { throw new Error('No image ID available for rasterization'); } @@ -286,59 +201,30 @@ export default defineComponent({ throw new Error('Rasterization is not supported for cine images'); } - const groups = segmentGroupStore.orderByParent[imageId.value]; - if (!groups?.length) { - throw new Error(`No segment group exists for image ${imageId.value}`); - } - - const segmentGroupID = groups[0]; - - // Switch to the correct segment group if needed - if (paintStore.activeSegmentGroupID !== segmentGroupID) { - paintStore.setActiveSegmentGroup(segmentGroupID); - paintStore.setActiveSegment(segment.value); - } - - const segmentGroup = segmentGroupStore.dataIndex[segmentGroupID]; - if (!segmentGroup) { - throw new Error( - `Failed to get segment group data for ${segmentGroupID}` - ); - } - - // Convert parent slice index to segment group slice index - const parentMeta = imageMetadata.value; - const segmentGroupSlice = convertSliceIndex( - slice.value, - parentMeta.lpsOrientation, - parentMeta.indexToWorld, - segmentGroup, - viewAxis.value - ); - - const points = activeToolStore.getPoints(toolId); - const segmentGroupIjkIndex = getLPSDirections( - segmentGroup.getDirection() - )[viewAxis.value]; - - const indexSpacePoints2D = points.map((pt) => { - const output = [...segmentGroup.worldToIndex(pt)]; - output.splice(segmentGroupIjkIndex, 1); - return output as Vector2; + const tool = activeToolStore.toolByID[toolId]; + const rasterized = rasterizePolygon({ + imageId: imageId.value, + segmentId: tool?.segmentId, + points: activeToolStore.getPoints(toolId), + slice: slice.value, + viewAxis: viewAxis.value, }); - - const grid = createGridAccessor( - segmentGroup, - segmentGroupSlice, - segmentGroupIjkIndex - ); - fillPoly(grid, indexSpacePoints2D, segment.value); - segmentGroup.modified(); + // The polygon records the type its voxels actually landed in: an + // unlabeled one, and one whose type was deleted, are given the type the + // edit resolved. A refused rasterize hands back what it was given. + if ( + tool && + rasterized.segmentId && + tool.segmentId !== rasterized.segmentId + ) { + activeToolStore.updateTool(toolId, { segmentId: rasterized.segmentId }); + } } return { tools: currentTools, placingToolID: placingTool.id, + onPlacementStarted: placingTool.beginPlacement, onToolPlaced, contextMenu, openContextMenu, @@ -348,7 +234,6 @@ export default defineComponent({ onHover, overlayInfo, rasterize, - currentSegmentGroup, isCurrentImageCine, }; }, diff --git a/src/components/tools/polygon/PolygonWidget2D.vue b/src/components/tools/polygon/PolygonWidget2D.vue index c5b5d2470..713527b49 100644 --- a/src/components/tools/polygon/PolygonWidget2D.vue +++ b/src/components/tools/polygon/PolygonWidget2D.vue @@ -19,6 +19,7 @@ import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { useRightClickContextMenu, useWidgetVisibility, + useToolAppearance, } from '@/src/composables/annotationTool'; import { getCSSCoordinatesFromEvent } from '@/src/utils/vtk-helpers'; import { usePolygonStore as useStore } from '@/src/store/tools/polygons'; @@ -36,7 +37,7 @@ import SVG2DComponent from './PolygonSVG2D.vue'; export default defineComponent({ name: 'PolygonWidget2D', - emits: ['placed', 'contextmenu', 'widgetHover'], + emits: ['placing', 'placed', 'contextmenu', 'widgetHover'], props: { toolId: { type: String as unknown as PropType, @@ -92,6 +93,12 @@ export default defineComponent({ } }); + // Fires on every handle dropped into the polygon being placed; only the + // first has a segment to resolve. + onVTKEvent(widget, 'onStartInteractionEvent', () => { + if (isPlacing.value) emit('placing'); + }); + onVTKEvent(widget, 'onPlacedEvent', () => { emit('placed'); }); @@ -166,6 +173,7 @@ export default defineComponent({ return { slice, tool, + appearance: useToolAppearance(toolStore, () => tool.value), editState, showHandles, }; @@ -177,8 +185,8 @@ export default defineComponent({ @@ -21,7 +22,6 @@ - - diff --git a/src/composables/__tests__/labelShortcuts.spec.ts b/src/composables/__tests__/labelShortcuts.spec.ts new file mode 100644 index 000000000..792a34c83 --- /dev/null +++ b/src/composables/__tests__/labelShortcuts.spec.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createApp } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { ACTION_TO_FUNC } from '@/src/composables/actions'; +import { CorePiniaProviderPlugin } from '@/src/core/provider'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useToolStore } from '@/src/store/tools'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { Tools } from '@/src/store/tools/types'; +import { useViewStore } from '@/src/store/views'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { boundMasks } from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +const seatAndView = (id: string) => { + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + useViewStore().setDataForAllViews(id); +}; + +describe('next/previous type shortcuts', () => { + beforeEach(() => { + const pinia = createPinia().use(CorePiniaProviderPlugin()); + createApp({}).use(pinia); + setActivePinia(pinia); + }); + + // The shared registry starts empty, so there is nothing to cycle. + it('is a no-op when the registry is empty', () => { + seatAndView('img-1'); + + expect(usePolygonStore().segments.segmentList.value).toEqual([]); + expect(() => ACTION_TO_FUNC.incrementLabel()).not.toThrow(); + expect(() => ACTION_TO_FUNC.decrementLabel()).not.toThrow(); + expect(usePolygonStore().segments.selectedSegmentId.value).toBeFalsy(); + }); + + it('is a no-op when no image is viewed', () => { + expect(() => ACTION_TO_FUNC.incrementLabel()).not.toThrow(); + expect(() => ACTION_TO_FUNC.decrementLabel()).not.toThrow(); + }); + + it('cycles through the registry the active tool reads', () => { + seatAndView('img-1'); + const { segments } = usePolygonStore(); + const first = segments.addSegment({ name: 'Tumor' }); + const second = segments.addSegment({ name: 'Node' }); + + useToolStore().setCurrentTool(Tools.Polygon); + segments.selectSegment(first); + ACTION_TO_FUNC.incrementLabel(); + expect(segments.selectedSegmentId.value).toBe(second); + + ACTION_TO_FUNC.incrementLabel(); + expect(segments.selectedSegmentId.value).toBe(first); + + ACTION_TO_FUNC.decrementLabel(); + expect(segments.selectedSegmentId.value).toBe(second); + }); + + // One registry serves every tool, so the shortcut is not scoped to the + // annotation tools that used to own their own labels. + it('cycles while paint is the active tool', () => { + seatAndView('img-1'); + useToolStore().setCurrentTool(Tools.Paint); + const { segments } = useSegmentStore(); + const first = segments.addSegment({ name: 'Tumor' }); + const second = segments.addSegment({ name: 'Node' }); + + segments.selectSegment(first); + ACTION_TO_FUNC.incrementLabel(); + + expect(segments.selectedSegmentId.value).toBe(second); + }); + + it.each([Tools.Paint, Tools.Polygon, Tools.Rectangle, Tools.Ruler])( + 'selects one segment for %s without allocating masks, including reactivation', + (tool) => { + seatAndView('img-1'); + const tools = useToolStore(); + const { segments } = useSegmentStore(); + tools.setCurrentTool(tool); + const first = segments.selectedSegmentId.value; + expect(first).toBeTruthy(); + expect(segments.segmentList.value).toHaveLength(1); + expect(useSegmentationStore().segmentations).toEqual({}); + expect(boundMasks()).toEqual([]); + + tools.setCurrentTool(Tools.Select); + tools.setCurrentTool(tool); + expect(segments.selectedSegmentId.value).toBe(first); + expect(segments.segmentList.value).toHaveLength(1); + const selected = segments.addSegment({ name: 'Another' }); + tools.setCurrentTool(Tools.Select); + tools.setCurrentTool(tool); + expect(segments.selectedSegmentId.value).toBe(selected); + expect(segments.segmentList.value).toHaveLength(2); + expect(useSegmentationStore().segmentations).toEqual({}); + expect(boundMasks()).toEqual([]); + } + ); +}); diff --git a/src/composables/__tests__/useKeyboardShortcuts.spec.ts b/src/composables/__tests__/useKeyboardShortcuts.spec.ts index f7655c5f2..2df309948 100644 --- a/src/composables/__tests__/useKeyboardShortcuts.spec.ts +++ b/src/composables/__tests__/useKeyboardShortcuts.spec.ts @@ -47,7 +47,7 @@ const insideTextbox = () => { }; describe('shouldIgnoreKeyboardShortcuts', () => { - describe('text entry keeps the keys it types with', () => { + describe('text entry keeps the keys it segments with', () => { it.each(['m', 'delete', 'backspace', 'arrowdown', 'home', '?'])( 'yields %s to a text input', (binding) => { diff --git a/src/composables/actions.ts b/src/composables/actions.ts index 1da140678..5ff9a68f4 100644 --- a/src/composables/actions.ts +++ b/src/composables/actions.ts @@ -1,8 +1,5 @@ import { removeSelectedTools, useToolStore } from '../store/tools'; import { Tools } from '../store/tools/types'; -import { useRectangleStore } from '../store/tools/rectangles'; -import { useRulerStore } from '../store/tools/rulers'; -import { usePolygonStore } from '../store/tools/polygons'; import { useViewStore } from '../store/views'; import { Action, NOOP } from '../constants'; import { useKeyboardShortcutsStore } from '../store/keyboard-shortcuts'; @@ -10,29 +7,31 @@ import { useCurrentImage } from './useCurrentImage'; import { useSliceConfig } from './useSliceConfig'; import { useCineFrame } from './useCineFrame'; import { useDatasetStore } from '../store/datasets'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { usePaintToolStore } from '../store/tools/paint'; import { PaintMode } from '../core/tools/paint'; import { computeEffectiveView } from '../core/views/effectiveView'; +import type { Segment } from '@/src/segmentation/segment'; + +// One registry holds the segments every tool draws into, so cycling it is not +// scoped to a tool: paint takes the selection the same way a polygon does. +const applySegmentOffset = (offset: number) => () => { + const { segments } = useSegmentStore(); + const ids = segments.segmentList.value.map((segment: Segment) => segment.id); + // A registry starts empty, so there is nothing to cycle until one exists. + if (ids.length === 0) return; + + const selected = segments.selectedSegmentId.value; + const selectedIndex = selected ? ids.indexOf(selected) : -1; + // A negative index wraps, so cycling back from the first lands on the last. + const next = ids.at((selectedIndex + offset) % ids.length); + if (next) segments.selectSegment(next); +}; -const applyLabelOffset = (offset: number) => () => { - const toolToStore = { - [Tools.Rectangle]: useRectangleStore(), - [Tools.Ruler]: useRulerStore(), - [Tools.Polygon]: usePolygonStore(), - }; - const toolStore = useToolStore(); - - // @ts-ignore - toolToStore may not have keys of all tools - const activeToolStore = toolToStore[toolStore.currentTool]; - if (!activeToolStore) return; - - const labels = Object.entries(activeToolStore.labels); - const activeLabelIndex = labels.findIndex( - ([name]) => name === activeToolStore.activeLabel - ); - - const [nextLabel] = labels.at((activeLabelIndex + offset) % labels.length)!; - activeToolStore.setActiveLabel(nextLabel); +const selectSegmentAt = (index: number) => () => { + const { segments } = useSegmentStore(); + const segment = segments.segmentList.value[index]; + if (segment) segments.selectSegment(segment.id); }; const setTool = (tool: Tools) => () => { @@ -95,6 +94,7 @@ export const ACTION_TO_FUNC = { ruler: setTool(Tools.Ruler), paint: startPaintInMode(PaintMode.CirclePaint), paintEraser: startPaintInMode(PaintMode.Erase), + paintEyedropper: NOOP, brushSizeModifier: NOOP, // act as modifier key rather than immediate effect, so no-op decreaseBrushSize: changeBrushSize(-1), increaseBrushSize: changeBrushSize(1), @@ -109,8 +109,18 @@ export const ACTION_TO_FUNC = { previousSlice: changeSlice(1), grabSlice: NOOP, // acts as a modifier key rather than immediate effect, so no-op - decrementLabel: applyLabelOffset(-1), - incrementLabel: applyLabelOffset(1), + decrementLabel: applySegmentOffset(-1), + incrementLabel: applySegmentOffset(1), + selectSegment1: selectSegmentAt(0), + selectSegment2: selectSegmentAt(1), + selectSegment3: selectSegmentAt(2), + selectSegment4: selectSegmentAt(3), + selectSegment5: selectSegmentAt(4), + selectSegment6: selectSegmentAt(5), + selectSegment7: selectSegmentAt(6), + selectSegment8: selectSegmentAt(7), + selectSegment9: selectSegmentAt(8), + selectSegment10: selectSegmentAt(9), deleteSelectedAnnotations: removeSelectedTools, diff --git a/src/composables/annotationTool.ts b/src/composables/annotationTool.ts index d19368baf..8f19c1a8e 100644 --- a/src/composables/annotationTool.ts +++ b/src/composables/annotationTool.ts @@ -78,7 +78,11 @@ export const useCurrentTools = ( return ( tool.imageID === curImageID && doesToolFrameMatchViewAxis(viewAxis, tool, currentImageMetadata) && - !tool.hidden + !tool.hidden && + // Keep the active placement widget alive until it commits. Completed + // shapes inherit the segment's visibility without changing child flags. + (tool.placing || + toolStore.segments.appearanceOf(tool.segmentId).visible) ); }); }); @@ -86,6 +90,12 @@ export const useCurrentTools = ( // --- Context Menu --- // +/** The appearance a shape draws itself in, resolved from the segment it names. */ +export const useToolAppearance = ( + store: AnnotationToolStore, + tool: () => Maybe<{ segmentId?: string }> +) => computed(() => store.segments.appearanceOf(tool()?.segmentId)); + export const useContextMenu = () => { const contextMenu = ref<{ open: (id: ToolID, e: ContextMenuEvent) => void; @@ -224,7 +234,7 @@ export const usePlacingAnnotationTool = ( const commit = () => { const id_ = id.value as Maybe; if (!id_) return; - store.updateTool(id_, { placing: false }); + store.placeTool(id_); id.value = null; }; @@ -248,8 +258,16 @@ export const usePlacingAnnotationTool = ( store.updateTool(id.value as ToolID, metadata.value); }); + // The first gesture is what mints, so the shape resolves its segment as + // placement starts rather than when it lands. + const beginPlacement = () => { + const id_ = id.value as Maybe; + if (id_) store.resolveToolType(id_); + }; + return { id: readonly(id), + beginPlacement, commit, add, remove, diff --git a/src/composables/useGlobalLayerColorConfig.ts b/src/composables/useGlobalLayerColorConfig.ts deleted file mode 100644 index cfad99c39..000000000 --- a/src/composables/useGlobalLayerColorConfig.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { computed, MaybeRef, unref } from 'vue'; -import useLayerColoringStore from '@/src/store/view-configs/layers'; -import { LayersConfig } from '@/src/store/view-configs/types'; -import { useViewStore } from '@/src/store/views'; - -// Returns first existing view's config as the "value" and updates all views' configs with updateConfig() -export const useGlobalLayerColorConfig = (layerId: MaybeRef) => { - const layerColoringStore = useLayerColoringStore(); - const viewStore = useViewStore(); - - const views2D = computed(() => - viewStore.getAllViews().filter((view) => view.type === '2D') - ); - - const layerConfigs = computed(() => - views2D.value.map((view) => ({ - config: layerColoringStore.getConfig(view.id, unref(layerId)), - viewID: view.id, - })) - ); - - const sampledConfig = computed(() => - layerConfigs.value.find(({ config }) => config) - ); - - const updateConfig = (patch: Partial) => { - layerConfigs.value.forEach(({ viewID }) => - layerColoringStore.updateConfig(viewID, unref(layerId), patch) - ); - }; - - return { sampledConfig, updateConfig }; -}; diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts index 85a312e39..5ab2a4982 100644 --- a/src/composables/useKeyboardShortcuts.ts +++ b/src/composables/useKeyboardShortcuts.ts @@ -3,6 +3,7 @@ import { DefaultMagicKeysAliasMap, onKeyStroke, useMagicKeys, + useActiveElement, } from '@vueuse/core'; import { getEntries, wrapInArray } from '../utils'; @@ -44,8 +45,13 @@ export const isDispatchable = (binding: string) => */ export const useActionHeld = (action: Action) => { const keys = useMagicKeys(); + const activeElement = useActiveElement(); return computed(() => - bindingsOf(actionToKey.value[action]).some((binding) => keys[binding].value) + bindingsOf(actionToKey.value[action]).some( + (binding) => + keys[binding].value && + !shouldIgnoreKeyboardShortcuts(binding, activeElement.value) + ) ); }; @@ -130,7 +136,7 @@ const matchesBinding = ( const NON_TEXT_INPUT_TYPES = new Set( 'button checkbox color file image radio range reset submit'.split(' ') ); -const TEXT_ENTRY_SELECTOR = 'input, textarea, [role="textbox"]'; +const TEXT_ENTRY_SELECTOR = 'input, textarea, select, [role="textbox"]'; const ARROW_KEYS = ['up', 'down', 'left', 'right'].map( (direction) => `arrow${direction}` diff --git a/src/composables/useMultipleToolSelection.ts b/src/composables/useMultipleToolSelection.ts deleted file mode 100644 index d04a92b63..000000000 --- a/src/composables/useMultipleToolSelection.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - ToolSelection, - useToolSelectionStore, -} from '@/src/store/tools/toolSelection'; -import { AnnotationToolType } from '@/src/store/tools/types'; -import { ToolID } from '@/src/types/annotation-tool'; -import { MaybeRef, computed, unref } from 'vue'; - -export enum MultipleSelectionState { - None, - Some, - All, -} - -export const useMultipleToolSelection = ( - collection: MaybeRef -) => { - const store = useToolSelectionStore(); - - const idToType = computed(() => - unref(collection).reduce( - (acc, { id, type }) => ({ ...acc, [id]: type }), - {} as Record - ) - ); - - const selected = computed({ - get: () => store.selection.map(({ id }) => id), - set: (newSelection) => { - store.clearSelection(); - newSelection.forEach((id) => { - store.addSelection(id, idToType.value[id]); - }); - }, - }); - - const selectionState = computed(() => { - const coll = unref(collection); - const { length } = unref(coll).filter((tool) => store.isSelected(tool.id)); - if (length === 0) { - return MultipleSelectionState.None; - } - if (length === coll.length) { - return MultipleSelectionState.All; - } - return MultipleSelectionState.Some; - }); - - const selectAll = () => { - unref(collection).forEach((tool) => store.addSelection(tool.id, tool.type)); - }; - - const deselectAll = () => { - // technically correct solution is collection.forEach(remove), - // but this will suffice (and is faster). - store.clearSelection(); - }; - - return { - selectAll, - deselectAll, - selected, - selectionState, - }; -}; diff --git a/src/config.ts b/src/config.ts index 830ceee3c..653b6d470 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,6 @@ import MRAHeadThumbnail from '@/src/assets/samples/MRA-Head_and_Neck.jpg'; import CTAHeadThumbnail from '@/src/assets/samples/CTA-Head_and_Neck.jpg'; import USFetusThumbnail from '@/src/assets/samples/3DUS-Fetus.jpg'; import USCineThumbnail from '@/src/assets/samples/US-Cine.jpg'; -import { SegmentMask } from '@/src/types/segment'; import type { LayoutConfig } from './utils/layoutParsing'; import type { ViewInfoInit } from './types/views'; import { SampleDataset } from './types'; @@ -171,18 +170,6 @@ export const TOOL_COLORS = [ export const STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT = 1; -export const RULER_LABEL_DEFAULTS = { - 'Label 1': { color: 'red' }, -}; - -export const RECTANGLE_LABEL_DEFAULTS = { - 'Label 1': { color: 'red' }, -}; - -export const POLYGON_LABEL_DEFAULTS = { - 'Label 1': { color: 'red' }, -}; - export const DEFAULT_PRESET_BY_MODALITY: Record = { CT: 'CT-AAA', MR: 'CT-Coronary-Arteries-2', @@ -204,6 +191,7 @@ export const ACTION_TO_KEY = { ruler: 'm', paint: 'p', paintEraser: 'e', + paintEyedropper: 'd', brushSizeModifier: 'ctrl', decreaseBrushSize: '[', increaseBrushSize: ']', @@ -221,6 +209,16 @@ export const ACTION_TO_KEY = { decrementLabel: 'q', incrementLabel: 'w', + selectSegment1: '1', + selectSegment2: '2', + selectSegment3: '3', + selectSegment4: '4', + selectSegment5: '5', + selectSegment6: '6', + selectSegment7: '7', + selectSegment8: '8', + selectSegment9: '9', + selectSegment10: '0', // the main delete key reports Backspace on macOS deleteSelectedAnnotations: ['delete', 'backspace'], @@ -231,15 +229,6 @@ export const ACTION_TO_KEY = { showKeyboardShortcuts: '?', } satisfies Record; -export const DEFAULT_SEGMENT_MASKS: SegmentMask[] = [ - { - value: 1, - name: 'Segment 1', - color: [255, 255, 0, 255], - visible: true, - }, -]; - // from https://github.com/InsightSoftwareConsortium/itk-viewer-color-maps/blob/main/src/CategoricalColors.json export const CATEGORICAL_COLORS = [ [214, 0, 0], diff --git a/src/constants.ts b/src/constants.ts index bc67f44c2..78d451a74 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,19 @@ export const PICKABLE_ANNOTATION_TOOL_HANDLE_RADIUS = export const IMAGE_DRAG_MEDIA_TYPE = 'application/x-volview-image-id'; +export const SEGMENT_SHORTCUT_ACTIONS = [ + 'selectSegment1', + 'selectSegment2', + 'selectSegment3', + 'selectSegment4', + 'selectSegment5', + 'selectSegment6', + 'selectSegment7', + 'selectSegment8', + 'selectSegment9', + 'selectSegment10', +] as const; + export const ACTIONS = { windowLevel: { readable: 'Activate Window/Level tool', @@ -46,6 +59,9 @@ export const ACTIONS = { paintEraser: { readable: 'Activate Paint tool with eraser', }, + paintEyedropper: { + readable: 'Temporarily pick a segment while holding key in Paint', + }, brushSizeModifier: { readable: 'Change brush size by holding key and scrolling', }, @@ -91,6 +107,17 @@ export const ACTIONS = { readable: 'Activate next label', }, + selectSegment1: { readable: 'Select segment 1' }, + selectSegment2: { readable: 'Select segment 2' }, + selectSegment3: { readable: 'Select segment 3' }, + selectSegment4: { readable: 'Select segment 4' }, + selectSegment5: { readable: 'Select segment 5' }, + selectSegment6: { readable: 'Select segment 6' }, + selectSegment7: { readable: 'Select segment 7' }, + selectSegment8: { readable: 'Select segment 8' }, + selectSegment9: { readable: 'Select segment 9' }, + selectSegment10: { readable: 'Select segment 10' }, + deleteSelectedAnnotations: { readable: 'Delete selected annotations', }, diff --git a/src/core/annotations/__tests__/snappedCenter.spec.ts b/src/core/annotations/__tests__/snappedCenter.spec.ts new file mode 100644 index 000000000..79860f3d6 --- /dev/null +++ b/src/core/annotations/__tests__/snappedCenter.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { snappedCenter } from '@/src/core/annotations/locator'; + +describe('snappedCenter', () => { + it('has nowhere to go when the segment holds nothing on the axis', () => { + expect(snappedCenter([])).toBeUndefined(); + }); + + it('takes the middle of a single span', () => { + expect(snappedCenter([[10, 20]])).toBe(15); + }); + + it('rounds a half-slice middle to one slice', () => { + expect(snappedCenter([[10, 15]])).toBe(13); + }); + + it('lands on a shape rather than beside it', () => { + expect(snappedCenter([[42, 42]])).toBe(42); + }); + + // The whole reason for snapping: content split across distant slices has an + // empty middle, and a view put there shows none of it. Equally distant + // shapes settle on the lower slice. + it('snaps out of the gap between two distant shapes', () => { + expect( + snappedCenter([ + [10, 10], + [90, 90], + ]) + ).toBe(10); + }); + + it('prefers the span nearer the middle', () => { + expect( + snappedCenter([ + [10, 10], + [60, 60], + [90, 90], + ]) + ).toBe(60); + }); + + it('stays inside a span that already covers the middle', () => { + expect( + snappedCenter([ + [0, 100], + [40, 42], + ]) + ).toBe(50); + }); + + it('reaches the near edge of the span closest to the middle', () => { + expect( + snappedCenter([ + [0, 10], + [80, 100], + ]) + ).toBe(80); + }); +}); diff --git a/src/core/annotations/locator.ts b/src/core/annotations/locator.ts index a658eb534..3791e2b94 100644 --- a/src/core/annotations/locator.ts +++ b/src/core/annotations/locator.ts @@ -13,7 +13,9 @@ import useViewSliceStore from '@/src/store/view-configs/slicing'; import useCinePlaybackStore from '@/src/store/view-configs/cine-playback'; import { computeEffectiveView, + getEffectiveView, EffectiveView, + volume2DViewsOfImage, } from '@/src/core/views/effectiveView'; type Locator = @@ -91,15 +93,23 @@ export function toolRenderSlice( return tool.slice ?? viewSlice ?? 0; } +function revealCineFrame(imageID: string, frame: number) { + const activeView = useViewStore().activeView; + const effective = getEffectiveView(activeView); + if ( + !activeView || + effective?.kind !== 'cine' || + effective.renderDataID !== imageID + ) + return; + useCinePlaybackStore().updateConfig(activeView, imageID, { frame }); +} + export function applyLocator(imageID: string, tool: AnnotationTool) { const viewStore = useViewStore(); if (tool.frame != null) { - const activeView = viewStore.activeView; - if (!activeView) return; - useCinePlaybackStore().updateConfig(activeView, imageID, { - frame: tool.frame, - }); + revealCineFrame(imageID, tool.frame); return; } @@ -119,3 +129,63 @@ export function applyLocator(imageID: string, tool: AnnotationTool) { viewSliceStore.updateConfig(view.id, imageID, { slice: tool.slice }); }); } + +/** + * The slice nearest the middle of everything `intervals` cover, snapped into an + * interval. Content split across distant slices has an empty middle, and a view + * put there shows nothing of what the user asked to see. + */ +export function snappedCenter(intervals: Array<[number, number]>) { + if (intervals.length === 0) return undefined; + const middle = + (Math.min(...intervals.map(([low]) => low)) + + Math.max(...intervals.map(([, high]) => high))) / + 2; + const nearest = intervals + .map(([low, high]) => Math.min(Math.max(middle, low), high)) + .reduce((best, slice) => + Math.abs(slice - middle) < Math.abs(best - middle) ? slice : best + ); + return Math.round(nearest); +} + +/** Where one segment sits on the viewed image, per view axis. */ +export type SegmentContent = { + /** Occupied painted slices for the image's i, j and k index axes. */ + paintedSlicesByIJK?: [number[], number[], number[]]; + /** The slice each shape of the segment was drawn on, by the axis it faces. */ + slicesByAxis: Partial>; + /** Cine frames containing shapes of this segment. */ + frames?: number[]; +}; + +/** + * Reveals an occupied frame in the active cine view, or centers every volume + * 2D view on the segment's content along its axis. Pan and zoom stay where the + * user left them. A view whose axis holds nothing does not move. + */ +export function revealSegmentContent(imageID: string, content: SegmentContent) { + const frame = snappedCenter( + (content.frames ?? []).map((value) => [value, value]) + ); + if (frame != null) revealCineFrame(imageID, frame); + + const { metadata } = useImage(imageID); + const { lpsOrientation } = metadata.value; + const viewSliceStore = useViewSliceStore(); + + volume2DViewsOfImage(imageID, useViewStore().getAllViews()).forEach( + ({ viewId, axis }) => { + const ijk = lpsOrientation[axis]; + const painted = (content.paintedSlicesByIJK?.[ijk] ?? []).map( + (slice) => [slice, slice] as [number, number] + ); + const drawn = (content.slicesByAxis[axis] ?? []).map( + (slice) => [slice, slice] as [number, number] + ); + const slice = snappedCenter([...painted, ...drawn]); + if (slice == null) return; + viewSliceStore.updateConfig(viewId, imageID, { slice }); + } + ); +} diff --git a/src/core/manifestRefs.ts b/src/core/manifestRefs.ts index 08f075174..d9e71c3d5 100644 --- a/src/core/manifestRefs.ts +++ b/src/core/manifestRefs.ts @@ -1,6 +1,6 @@ // Manifest-reference declarations for the remove-cascade save backstop. // -// Every store that keeps dataset/view/segment-group-keyed manifest state clean +// Every store that keeps dataset/view/segmentation-keyed manifest state clean // via a remove cascade (an `onImageDeleted` registration or an equivalent sync // watch) also declares, at module scope next to that cascade, how to find its // references in a save manifest. The dev-only backstop in @@ -12,7 +12,7 @@ // evaluation from store modules, and any import here could turn that into a // cycle. -export type ManifestRefKind = 'dataset' | 'segmentGroup' | 'view'; +export type ManifestRefKind = 'dataset' | 'segment' | 'view'; export type ManifestRef = { kind: ManifestRefKind; diff --git a/src/core/streaming/__tests__/dicomChunkImage.spec.ts b/src/core/streaming/__tests__/dicomChunkImage.spec.ts index 10c66f619..0474b3edd 100644 --- a/src/core/streaming/__tests__/dicomChunkImage.spec.ts +++ b/src/core/streaming/__tests__/dicomChunkImage.spec.ts @@ -96,6 +96,72 @@ function sliceOf(image: DicomChunkImage, index: number) { ); } +// A decode held open per pixel value, so a test settles each attempt in the +// order it chooses. A chunk redecoded after a re-sort has one settler per +// attempt, oldest first; a settler given an error rejects instead of resolving. +function deferredDecoder() { + const pending = new Map void>>(); + const read: DicomChunkImageInit['readDicomImage'] = async (file) => { + const value = Number(await file.text()); + return new Promise((resolve, reject) => { + const settlers = pending.get(value) ?? []; + settlers.push((err) => { + if (err) reject(err); + else + resolve({ + image: { + size: [COLUMNS, ROWS, 1], + data: new Uint16Array(PIXELS_PER_SLICE).fill(value), + imageType: { components: 1 }, + }, + }); + }); + pending.set(value, settlers); + }); + }; + return { pending, read }; +} + +// Chunk 3 starts alone in slot 0, then chunks 1 and 2 arrive and re-sort it +// into slot 2 while its first decode is still outstanding. `pending.get(3)` +// then holds the stale attempt at index 0 and the current one at index 1. +async function imageWithResortedChunk() { + const { pending, read } = deferredDecoder(); + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage: read, + }); + + const errors: number[] = []; + image.addEventListener('chunkError', ({ chunk }) => { + errors.push(zOf(chunk)); + }); + + const [first, second, third] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + makeLoadedChunk(3), + ]); + + // Start chunk 3 in slot 0, then move it to slot 2 while decoding. + await image.addChunks([third]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); + + await image.addChunks([first, second]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(2)); + + return { image, pending, errors }; +} + +// Asserts the volume's leading slices hold the pixels of the chunks that +// belong in them; a chunk's z position is also its pixel value, and 0 is a +// slice no chunk has written. +function expectSliceValues(image: DicomChunkImage, values: number[]) { + values.forEach((value, index) => + expect(sliceOf(image, index)).toEqual(Array(PIXELS_PER_SLICE).fill(value)) + ); +} + async function loadRejectingSeries( read: DicomChunkImageInit['readDicomImage'] ) { @@ -122,8 +188,7 @@ async function loadRejectingSeries( expect(image.status.value).toBe('complete'); expect(errors).toHaveLength(1); - expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); - expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(0)); + expectSliceValues(image, [1, 0]); image.dispose(); return String(errors[0]); } @@ -232,8 +297,7 @@ describe('DicomChunkImage', () => { ); expect(image.getChunks().map(zOf)).toEqual([1, 2]); - expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); - expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); + expectSliceValues(image, [1, 2]); image.dispose(); }); @@ -270,60 +334,22 @@ describe('DicomChunkImage', () => { { z: 2, zRange: [1, 1] }, { z: 3, zRange: [2, 2] }, ]); - expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); - expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); - expect(sliceOf(image, 2)).toEqual(Array(PIXELS_PER_SLICE).fill(3)); + expectSliceValues(image, [1, 2, 3]); image.dispose(); }); it('keeps a stale in-flight decode from clobbering the re-sorted volume', async () => { - // Hold each decode independently by its pixel value. - const pending = new Map void>>(); - const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( - file - ) => { - const value = Number(await file.text()); - return new Promise((resolve) => { - const resolvers = pending.get(value) ?? []; - resolvers.push(() => - resolve({ - image: { - size: [COLUMNS, ROWS, 1], - data: new Uint16Array(PIXELS_PER_SLICE).fill(value), - imageType: { components: 1 }, - }, - }) - ); - pending.set(value, resolvers); - }); - }; - - const image = new DicomChunkImage({ - splitAndSort: splitAndSortByPosition, - readDicomImage: deferredRead, - }); + const { image, pending } = await imageWithResortedChunk(); let loads = 0; image.addEventListener('chunkLoad', () => { loads += 1; }); - const [first, second, third] = await Promise.all([ - makeLoadedChunk(1), - makeLoadedChunk(2), - makeLoadedChunk(3), - ]); - - // Start chunk 3 in slot 0, then move it to slot 2 while decoding. - await image.addChunks([third]); - await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); - - await image.addChunks([first, second]); await vi.waitFor(() => { expect(pending.get(1)).toHaveLength(1); expect(pending.get(2)).toHaveLength(1); - expect(pending.get(3)).toHaveLength(2); }); // Complete the current decodes before the stale attempt. @@ -337,59 +363,13 @@ describe('DicomChunkImage', () => { await Promise.resolve(); expect(loads).toBe(3); - expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); - expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); - expect(sliceOf(image, 2)).toEqual(Array(PIXELS_PER_SLICE).fill(3)); + expectSliceValues(image, [1, 2, 3]); image.dispose(); }); it('does not let a stale success overwrite a replacement failure', async () => { - // Hold each decode independently by its pixel value. - const pending = new Map void>>(); - const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( - file - ) => { - const value = Number(await file.text()); - return new Promise((resolve, reject) => { - const settlers = pending.get(value) ?? []; - settlers.push((err) => { - if (err) reject(err); - else - resolve({ - image: { - size: [COLUMNS, ROWS, 1], - data: new Uint16Array(PIXELS_PER_SLICE).fill(value), - imageType: { components: 1 }, - }, - }); - }); - pending.set(value, settlers); - }); - }; - - const image = new DicomChunkImage({ - splitAndSort: splitAndSortByPosition, - readDicomImage: deferredRead, - }); - - const errors: number[] = []; - image.addEventListener('chunkError', ({ chunk }) => { - errors.push(zOf(chunk)); - }); - - const [first, second, third] = await Promise.all([ - makeLoadedChunk(1), - makeLoadedChunk(2), - makeLoadedChunk(3), - ]); - - // Start chunk 3 in slot 0, then move it to slot 2 while decoding. - await image.addChunks([third]); - await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); - - await image.addChunks([first, second]); - await vi.waitFor(() => expect(pending.get(3)).toHaveLength(2)); + const { image, pending, errors } = await imageWithResortedChunk(); pending.get(1)![0](); pending.get(2)![0](); @@ -419,49 +399,7 @@ describe('DicomChunkImage', () => { }); it('does not let a stale failure overwrite a replacement success', async () => { - const pending = new Map void>>(); - const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( - file - ) => { - const value = Number(await file.text()); - return new Promise((resolve, reject) => { - const settlers = pending.get(value) ?? []; - settlers.push((err) => { - if (err) reject(err); - else - resolve({ - image: { - size: [COLUMNS, ROWS, 1], - data: new Uint16Array(PIXELS_PER_SLICE).fill(value), - imageType: { components: 1 }, - }, - }); - }); - pending.set(value, settlers); - }); - }; - - const image = new DicomChunkImage({ - splitAndSort: splitAndSortByPosition, - readDicomImage: deferredRead, - }); - - const errors: number[] = []; - image.addEventListener('chunkError', ({ chunk }) => { - errors.push(zOf(chunk)); - }); - - const [first, second, third] = await Promise.all([ - makeLoadedChunk(1), - makeLoadedChunk(2), - makeLoadedChunk(3), - ]); - - await image.addChunks([third]); - await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); - - await image.addChunks([first, second]); - await vi.waitFor(() => expect(pending.get(3)).toHaveLength(2)); + const { image, pending, errors } = await imageWithResortedChunk(); pending.get(1)![0](); pending.get(2)![0](); @@ -486,27 +424,10 @@ describe('DicomChunkImage', () => { }); it('reports a reallocated chunk as loading until its slice is rewritten', async () => { - const pending: Array<() => void> = []; - const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( - file - ) => { - const value = Number(await file.text()); - return new Promise((resolve) => { - pending.push(() => - resolve({ - image: { - size: [COLUMNS, ROWS, 1], - data: new Uint16Array(PIXELS_PER_SLICE).fill(value), - imageType: { components: 1 }, - }, - }) - ); - }); - }; - + const { pending, read } = deferredDecoder(); const image = new DicomChunkImage({ splitAndSort: splitAndSortByPosition, - readDicomImage: deferredRead, + readDicomImage: read, }); const [first, second] = await Promise.all([ @@ -515,8 +436,8 @@ describe('DicomChunkImage', () => { ]); await image.addChunks([first]); - await vi.waitFor(() => expect(pending).toHaveLength(1)); - pending[0](); + await vi.waitFor(() => expect(pending.get(1)).toHaveLength(1)); + pending.get(1)![0](); await vi.waitFor(() => expect(image.status.value).toBe('complete')); // Reallocation cleared chunk 1, and neither replacement decode has run. @@ -529,11 +450,14 @@ describe('DicomChunkImage', () => { expect(image.status.value).toBe('incomplete'); expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(0)); - await vi.waitFor(() => expect(pending).toHaveLength(3)); - pending.slice(1).forEach((settle) => settle()); + await vi.waitFor(() => { + expect(pending.get(1)).toHaveLength(2); + expect(pending.get(2)).toHaveLength(1); + }); + pending.get(1)![1](); + pending.get(2)![0](); await vi.waitFor(() => expect(image.status.value).toBe('complete')); - expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); - expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); + expectSliceValues(image, [1, 2]); image.dispose(); }); diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 86f8b2fd4..4ff02ef18 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -1,5 +1,5 @@ import { - buildSegmentGroups, + readDicomSegmentation, ReadOverlappingSegmentationMeta, readVolumeSlice, splitAndSort as splitAndSortChunks, @@ -96,6 +96,39 @@ export interface DicomChunkImageInit { }>; } +type DecodedChunkImage = Awaited< + ReturnType +>['image']; + +// The buffer is allocated for the range every chunk's tags declare, so a chunk +// only fails here when its decoded values disagree with its tags. +// TypedArray.set raises nothing for such values: integers wrap and fractions +// truncate. +function assertSamplesRepresentable( + decoded: ArrayLike, + range: { min: number; max: number }, + buffer: TypedArray, + where: string +) { + if (!valuesFitBuffer(range, buffer)) { + const bufferRange = getBufferValueRange(buffer)!; + throw new Error( + `${where} has pixel values the volume it belongs to cannot represent. ` + + `Its pixel values run from ${range.min} to ${range.max}, but the volume's buffer is ` + + `${buffer.constructor.name}, holding values from ${bufferRange.min} to ${bufferRange.max}. ` + + `Every file in a volume must decode to values its buffer can hold without conversion.` + ); + } + if (!samplesAreIntegral(decoded, buffer)) { + throw new Error( + `${where} has fractional pixel values the volume it belongs to cannot represent. ` + + `Its pixel values run from ${range.min} to ${range.max}, but the volume's buffer is ` + + `${buffer.constructor.name}, which holds only whole numbers. ` + + `Every file in a volume must decode to values its buffer can hold without conversion.` + ); + } +} + export default class DicomChunkImage extends BaseProgressiveImage implements ChunkImage @@ -415,7 +448,7 @@ export default class DicomChunkImage `Cannot handle multiple SEG files. Expected 1 chunk at index 0, got ${this.chunks.length} chunks with current index ${this.chunks.indexOf(chunk)}` ); - const results = await buildSegmentGroups( + const results = await readDicomSegmentation( new File([chunk.dataBlob!], 'seg.dcm') ); if (generation !== this.allocationGeneration) return; @@ -430,6 +463,52 @@ export default class DicomChunkImage this.onChunksUpdated(); } + // The slot layout gives one frame per file, so several files that each carry + // several frames have nowhere to go. + private assertSingleFramePerFile(frames: number, where: string) { + if (frames > 1 && this.chunks.length > 1) { + // we're trying to load multiple chunks where individual chunks have multiple frames + throw new Error( + `Loading a single volume from multiple DICOM files where individual files contain multiple frames is not supported. ` + + `${where} contains ${frames} frames.` + ); + } + } + + // Each chunk gets a fixed slot: one frame per chunk in a multi-file volume, + // or the whole volume when a single multi-frame chunk fills it. A decoded + // chunk has to fill its slot exactly. + private assertChunkFitsSlot( + image: DecodedChunkImage, + volume: { dims: number[]; componentCount: number }, + where: string + ) { + const { dims, componentCount } = volume; + const multiFile = this.chunks.length > 1; + const framesPerChunk = multiFile ? 1 : dims[2]; + const [chunkWidth, chunkHeight] = image.size; + const chunkFrames = image.size[2] ?? 1; + const chunkComponents = image.imageType.components; + if ( + chunkWidth !== dims[0] || + chunkHeight !== dims[1] || + chunkFrames !== framesPerChunk || + chunkComponents !== componentCount + ) { + // A lone chunk defines the volume it fails to fit, so advice about + // agreeing with the other files only makes sense for a multi-file volume. + const advice = multiFile + ? ' Every file in a volume must have the same Rows, Columns, and SamplesPerPixel.' + : ''; + throw new Error( + `${where} does not fit the volume it belongs to. ` + + `It decoded to ${chunkWidth}x${chunkHeight}x${chunkFrames} with ${chunkComponents} component(s), ` + + `but the volume has room for ${dims[0]}x${dims[1]}x${framesPerChunk} with ${componentCount} component(s).` + + advice + ); + } + } + private async onRegularChunkHasData(chunk: Chunk, generation: number) { const chunkIndex = this.chunks.indexOf(chunk); if (!chunk.dataBlob) @@ -450,13 +529,8 @@ export default class DicomChunkImage const sliceIndex = this.chunks.indexOf(chunk); if (sliceIndex === -1) return; - if (result.image.size[2] > 1 && this.chunks.length > 1) { - // we're trying to load multiple chunks where individual chunks have multiple frames - throw new Error( - `Loading a single volume from multiple DICOM files where individual files contain multiple frames is not supported. ` + - `File ${chunkId} (chunk ${sliceIndex}) contains ${result.image.size[2]} frames.` - ); - } + const where = `File ${chunkId} (chunk ${sliceIndex})`; + this.assertSingleFramePerFile(result.image.size[2], where); const scalars = this.vtkImageData.value.getPointData().getScalars(); const pixelData = scalars.getData() as TypedArray; @@ -464,31 +538,7 @@ export default class DicomChunkImage const dims = this.vtkImageData.value.getDimensions(); - // Each chunk gets a fixed slot: one frame per chunk in a multi-file - // volume, or the whole volume when a single multi-frame chunk fills it. - const framesPerChunk = this.chunks.length > 1 ? 1 : dims[2]; - const [chunkWidth, chunkHeight] = result.image.size; - const chunkFrames = result.image.size[2] ?? 1; - const chunkComponents = result.image.imageType.components; - if ( - chunkWidth !== dims[0] || - chunkHeight !== dims[1] || - chunkFrames !== framesPerChunk || - chunkComponents !== componentCount - ) { - // A lone chunk defines the volume it fails to fit, so advice about - // agreeing with the other files only makes sense for a multi-file volume. - const advice = - this.chunks.length > 1 - ? ' Every file in a volume must have the same Rows, Columns, and SamplesPerPixel.' - : ''; - throw new Error( - `File ${chunkId} (chunk ${sliceIndex}) does not fit the volume it belongs to. ` + - `It decoded to ${chunkWidth}x${chunkHeight}x${chunkFrames} with ${chunkComponents} component(s), ` + - `but the volume has room for ${dims[0]}x${dims[1]}x${framesPerChunk} with ${componentCount} component(s).` + - advice - ); - } + this.assertChunkFitsSlot(result.image, { dims, componentCount }, where); const chunkDataRange: Array<[number, number]> = []; for (let comp = 0; comp < componentCount; comp++) { @@ -500,30 +550,14 @@ export default class DicomChunkImage chunkDataRange.push([min, max]); } - // The buffer is allocated for the range every chunk's tags declare, so a - // chunk only fails here when its decoded values disagree with its tags. - // TypedArray.set raises nothing for such values: integers wrap and - // fractions truncate. const chunkMin = Math.min(...chunkDataRange.map(([min]) => min)); const chunkMax = Math.max(...chunkDataRange.map(([, max]) => max)); - const decoded = result.image.data as unknown as ArrayLike; - if (!valuesFitBuffer({ min: chunkMin, max: chunkMax }, pixelData)) { - const bufferRange = getBufferValueRange(pixelData)!; - throw new Error( - `File ${chunkId} (chunk ${sliceIndex}) has pixel values the volume it belongs to cannot represent. ` + - `Its pixel values run from ${chunkMin} to ${chunkMax}, but the volume's buffer is ` + - `${pixelData.constructor.name}, holding values from ${bufferRange.min} to ${bufferRange.max}. ` + - `Every file in a volume must decode to values its buffer can hold without conversion.` - ); - } - if (!samplesAreIntegral(decoded, pixelData)) { - throw new Error( - `File ${chunkId} (chunk ${sliceIndex}) has fractional pixel values the volume it belongs to cannot represent. ` + - `Its pixel values run from ${chunkMin} to ${chunkMax}, but the volume's buffer is ` + - `${pixelData.constructor.name}, which holds only whole numbers. ` + - `Every file in a volume must decode to values its buffer can hold without conversion.` - ); - } + assertSamplesRepresentable( + result.image.data as unknown as ArrayLike, + { min: chunkMin, max: chunkMax }, + pixelData, + where + ); const offset = dims[0] * dims[1] * componentCount * sliceIndex; pixelData.set(result.image.data as TypedArray, offset); diff --git a/src/core/tools/__tests__/paint.spec.ts b/src/core/tools/__tests__/paint.spec.ts index 3363e40f8..b0f5c80b5 100644 --- a/src/core/tools/__tests__/paint.spec.ts +++ b/src/core/tools/__tests__/paint.spec.ts @@ -90,7 +90,7 @@ describe('Paint Tool', () => { const tool = new PaintTool(); tool.setBrushValue(brushValue); tool.setBrushSize(1); - tool.paintLabelmap(labelmap, 2, [0, 0, 0], [3, 3, 0]); + tool.paintLabelmap(labelmap, 2, [0, 0, 0], { endPoint: [3, 3, 0] }); for (let i = 0; i <= 3; i++) { const offset = i + 4 * i; expect(points[offset]).to.equal(brushValue); diff --git a/src/core/tools/paint/__tests__/fillHoles.spec.ts b/src/core/tools/paint/__tests__/fillHoles.spec.ts deleted file mode 100644 index 1f77280bd..000000000 --- a/src/core/tools/paint/__tests__/fillHoles.spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { fillHoles } from '../fillHoles'; - -// Build a flat single-slice label map (axis 2, k=0) from a 2D grid. -// grid[j][i] maps to flat index i + j*dimI. -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] }; -} - -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 = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [1, 1, 1], - [1, 1, 1], - [1, 1, 1], - ]).data - ) - ); - }); - - it('leaves border-connected background untouched', () => { - const { data, dimensions } = flatFromGrid([ - [0, 1, 1], - [1, 0, 1], - [1, 1, 1], - ]); - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - // The top-left 0 reaches the border, so it stays 0; the center is enclosed. - expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [0, 1, 1], - [1, 1, 1], - [1, 1, 1], - ]).data - ) - ); - }); - - 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); - fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - expect(Array.from(data)).toEqual(before); - }); - - it('all-segments: fills a hole with the majority bordering label', () => { - const { data, dimensions } = flatFromGrid([ - [5, 5, 5], - [7, 0, 5], - [5, 5, 5], - ]); - // The center 0 borders three 5s and one 7, so the majority is 5. - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - expect(out[1 + 1 * 3]).toBe(5); - }); - - it('selected-segment: fills enclosed background but preserves encircled segments', () => { - // 7 wide x 5 tall. Left block is a ring of 1 enclosing 0s and 2s; a stray - // 2 sits outside the ring on the right border. - const { data, dimensions } = flatFromGrid([ - [1, 1, 1, 1, 1, 0, 2], - [1, 0, 2, 0, 1, 0, 0], - [1, 2, 2, 2, 1, 0, 0], - [1, 0, 2, 0, 1, 0, 0], - [1, 1, 1, 1, 1, 0, 0], - ]); - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - label: 1, - }); - // Enclosed background (0) becomes 1; the enclosed 2s stay 2. - expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [1, 1, 1, 1, 1, 0, 2], - [1, 1, 2, 1, 1, 0, 0], - [1, 2, 2, 2, 1, 0, 0], - [1, 1, 2, 1, 1, 0, 0], - [1, 1, 1, 1, 1, 0, 0], - ]).data - ) - ); - }); - - it('selected-segment: does not override a segment it fully encircles', () => { - // Segment 1 forms a ring around segment 2 with a background gap between. - const { data, dimensions } = flatFromGrid([ - [1, 1, 1, 1, 1], - [1, 0, 0, 0, 1], - [1, 0, 2, 0, 1], - [1, 0, 0, 0, 1], - [1, 1, 1, 1, 1], - ]); - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - label: 1, - }); - // The background gap fills with 1; the encircled 2 is untouched. - expect(Array.from(out)).toEqual( - Array.from( - flatFromGrid([ - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 2, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - ]).data - ) - ); - }); - - 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); - }); - - it('all-segments: breaks majority ties by the lowest label', () => { - // The center borders two 8s (reached first by the flood) and two 3s. The - // lower label must win regardless of traversal order, so this fails if ties - // fall back to insertion order. - const { data, dimensions } = flatFromGrid([ - [8, 8, 3], - [8, 0, 3], - [8, 3, 3], - ]); - const out = fillHoles({ data, dimensions, axis: 2, sliceIndex: 0 }); - expect(out[1 + 1 * 3]).toBe(3); - }); - - it('all-segments: does not grow a locked segment into a hole', () => { - const { data, dimensions } = flatFromGrid([ - [5, 5, 5], - [5, 0, 5], - [5, 5, 5], - ]); - // 5 is locked, so its enclosed hole is left as background. - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - lockedLabels: [5], - }); - expect(out[1 + 1 * 3]).toBe(0); - }); - - it('all-segments: fills with the unlocked majority even when a locked label borders', () => { - const { data, dimensions } = flatFromGrid([ - [5, 5, 5], - [7, 0, 5], - [5, 5, 5], - ]); - // Majority 5 (unlocked) wins over the single locked 7 neighbor. - const out = fillHoles({ - data, - dimensions, - axis: 2, - sliceIndex: 0, - lockedLabels: [7], - }); - expect(out[1 + 1 * 3]).toBe(5); - }); -}); diff --git a/src/core/tools/paint/fillHoles.ts b/src/core/tools/paint/fillHoles.ts deleted file mode 100644 index 5ccb8e8c0..000000000 --- a/src/core/tools/paint/fillHoles.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { TypedArray } from '@kitware/vtk.js/types'; - -// 4-connected neighbor offsets, shared so the flood-fill loops never allocate -// a neighbor array per visited voxel. -const NEIGHBOR_DU = [-1, 1, 0, 0]; -const NEIGHBOR_DV = [0, 0, -1, 1]; - -export type FillHolesOptions = { - // Flat label-map scalar array, indexed as i + j*dimI + k*dimI*dimJ. - data: TypedArray | number[]; - // Label-map 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; - // When set, only this label is treated as foreground (selected-segment mode) - // and enclosed background is filled with it. When omitted, every non-zero - // voxel is foreground (all-segments mode) and enclosed background is filled - // with the majority bordering label. In both modes only background (0) - // voxels are filled, so existing segments are never overwritten. - label?: number; - // All-segments mode only: labels that must not be grown. A hole whose - // majority bordering label is locked is left unfilled rather than expanding a - // locked segment. - lockedLabels?: number[]; -}; - -// Fills enclosed background regions ("holes") on 2D slices of a label map. -// A hole is background that does not connect to the slice border. Only -// background (0) voxels are filled; other segments are never overwritten, even -// when enclosed by the foreground. Returns a copy of `data`; the input is left -// untouched. -export function fillHoles(opts: FillHolesOptions) { - const { data, dimensions, axis, sliceIndex, label, lockedLabels } = opts; - const out = data.slice(); - const lockedSet = lockedLabels?.length ? new Set(lockedLabels) : null; - - const strides = [1, dimensions[0], dimensions[0] * dimensions[1]]; - const sliceStride = strides[axis]; - const sliceCount = dimensions[axis]; - - // The two in-plane axes (everything that isn't the slice axis). - const [uAxis, vAxis] = [0, 1, 2].filter((a) => a !== axis); - const uDim = dimensions[uAxis]; - const vDim = dimensions[vAxis]; - const uStride = strides[uAxis]; - const vStride = strides[vAxis]; - const planeSize = uDim * vDim; - - const isForeground = - label === undefined - ? (value: number) => value !== 0 - : (value: number) => value === label; - // Only all-segments mode needs to tally each hole's bordering labels. - const trackBorders = label === undefined; - - // 0 = unvisited, 1 = outside (border-connected non-foreground), 2 = hole. - const visited = new Uint8Array(planeSize); - const stack: number[] = []; - - const firstSlice = sliceIndex ?? 0; - const lastSlice = sliceIndex ?? sliceCount - 1; - - for (let slice = firstSlice; slice <= lastSlice; slice++) { - const base = slice * sliceStride; - visited.fill(0); - - const planeOffset = (u: number, v: number) => - base + u * uStride + v * vStride; - - // 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; `onBorder`, when given, is called with - // each bordering foreground label. - const drain = ( - mark: number, - collect?: number[], - onBorder?: (value: number) => void - ) => { - while (stack.length) { - const p = stack.pop()!; - const u = p % uDim; - const v = (p - u) / uDim; - if (collect) collect.push(planeOffset(u, v)); - for (let n = 0; n < 4; n++) { - const nu = u + NEIGHBOR_DU[n]; - const nv = v + NEIGHBOR_DV[n]; - if (nu < 0 || nu >= uDim || nv < 0 || nv >= vDim) continue; - const np = nu + nv * uDim; - const nValue = out[planeOffset(nu, nv)]; - if (isForeground(nValue)) { - onBorder?.(nValue); - } else if (visited[np] === 0) { - visited[np] = mark; - stack.push(np); - } - } - } - }; - - // Flood non-foreground cells reachable from the slice border ("outside"). - const seedOutside = (u: number, v: number) => { - const p = u + v * uDim; - if (visited[p] === 0 && !isForeground(out[planeOffset(u, v)])) { - visited[p] = 1; - stack.push(p); - } - }; - for (let u = 0; u < uDim; u++) { - seedOutside(u, 0); - seedOutside(u, vDim - 1); - } - for (let v = 0; v < vDim; v++) { - seedOutside(0, v); - seedOutside(uDim - 1, v); - } - drain(1); - - // Any non-foreground cell not marked "outside" is part of a hole. Group - // each hole into a connected component and fill it. - for (let v = 0; v < vDim; v++) { - for (let u = 0; u < uDim; u++) { - const p = u + v * uDim; - if (visited[p] !== 0 || isForeground(out[planeOffset(u, v)])) continue; - - const holeCells: number[] = []; - const borderLabelCounts = trackBorders - ? new Map() - : null; - visited[p] = 2; - stack.push(p); - drain( - 2, - holeCells, - borderLabelCounts - ? (value) => - borderLabelCounts.set( - value, - (borderLabelCounts.get(value) ?? 0) + 1 - ) - : undefined - ); - - let fillValue = label; - if (fillValue === undefined && borderLabelCounts) { - // All-segments mode: fill with the majority bordering label, breaking - // ties by lowest label so the result is deterministic. Never fill - // with a locked label, which would grow a locked segment. - let bestCount = 0; - let bestLabel = -1; - borderLabelCounts.forEach((count, value) => { - if ( - count > bestCount || - (count === bestCount && value < bestLabel) - ) { - bestCount = count; - bestLabel = value; - } - }); - if (bestLabel !== -1 && !lockedSet?.has(bestLabel)) { - fillValue = bestLabel; - } - } - // fillValue stays undefined only when the hole had no fillable border - // (no foreground neighbors, or every bordering label is locked). - if (fillValue !== undefined) { - for (let c = 0; c < holeCells.length; c++) { - // Only fill background; never overwrite another segment, even when - // it is enclosed by the foreground. - if (out[holeCells[c]] === 0) { - out[holeCells[c]] = fillValue; - } - } - } - } - } - } - - return out; -} diff --git a/src/core/tools/paint/gaussianSmooth.worker.ts b/src/core/tools/paint/gaussianSmooth.worker.ts deleted file mode 100644 index 5fda51fd1..000000000 --- a/src/core/tools/paint/gaussianSmooth.worker.ts +++ /dev/null @@ -1,338 +0,0 @@ -import * as Comlink from 'comlink'; -import { TypedArray } from '@kitware/vtk.js/types'; -import { createTypedArrayLike } from '@/src/utils'; - -export interface GaussianSmoothParams { - sigma: number; - label: number; -} - -export interface GaussianSmoothInput { - data: TypedArray | number[]; - dimensions: number[]; - spacing: [number, number, number]; - params: GaussianSmoothParams; -} - -function generateGaussianKernel(sigma: number, radiusFactor = 1.5) { - const radius = Math.ceil(sigma * radiusFactor); - const size = 2 * radius + 1; - const kernel = new Float32Array(size); - const center = radius; - let sum = 0; - - for (let i = 0; i < size; i++) { - const x = i - center; - // VTK formula: exp(-(x * x) / (std * std * 2.0)) - const value = Math.exp(-(x * x) / (sigma * sigma * 2.0)); - kernel[i] = value; - sum += value; - } - - // Normalize kernel - for (let i = 0; i < size; i++) { - kernel[i] /= sum; - } - - return kernel; -} - -function convolve1D( - inputData: TypedArray | number[], - outputData: TypedArray | number[], - dimensions: number[], - kernel: Float32Array, - axis: 0 | 1 | 2 -) { - const [dimX, dimY, dimZ] = dimensions; - const kernelSize = kernel.length; - const kernelCenter = Math.floor(kernelSize / 2); - const strideY = dimX; - const strideZ = dimX * dimY; - - // Helper for robust boundary handling (mirroring) - const getFinalCoord = (sampleCoord: number, axisDim: number) => { - let finalCoord = sampleCoord; - if (sampleCoord < 0) { - finalCoord = -sampleCoord; // Reflect - } else if (sampleCoord >= axisDim) { - finalCoord = 2 * axisDim - sampleCoord - 2; // Reflect - } - // Clamp to ensure it's within bounds, useful if kernel is very large - return Math.max(0, Math.min(axisDim - 1, finalCoord)); - }; - - if (axis === 0) { - // Convolve along X: optimal loop order is z, y, x for cache efficiency - for (let z = 0; z < dimZ; z++) { - const zOffset = z * strideZ; - for (let y = 0; y < dimY; y++) { - const yOffset = y * strideY; - const baseOffset = yOffset + zOffset; - for (let x = 0; x < dimX; x++) { - let sum = 0; - for (let k = 0; k < kernelSize; k++) { - const sampleX = getFinalCoord(x + k - kernelCenter, dimX); - const sampleIdx = sampleX + baseOffset; - sum += inputData[sampleIdx] * kernel[k]; - } - - outputData[x + baseOffset] = sum; - } - } - } - } else if (axis === 1) { - // Convolve along Y: optimal loop order is z, x, y - for (let z = 0; z < dimZ; z++) { - const zOffset = z * strideZ; - for (let x = 0; x < dimX; x++) { - const baseOffset = x + zOffset; - for (let y = 0; y < dimY; y++) { - let sum = 0; - for (let k = 0; k < kernelSize; k++) { - const sampleY = getFinalCoord(y + k - kernelCenter, dimY); - const sampleIdx = baseOffset + sampleY * strideY; - sum += inputData[sampleIdx] * kernel[k]; - } - - outputData[x + y * strideY + zOffset] = sum; - } - } - } - } else { - // axis === 2, convolve along Z: optimal loop order is y, x, z - for (let y = 0; y < dimY; y++) { - const yOffset = y * strideY; - for (let x = 0; x < dimX; x++) { - const baseOffset = x + yOffset; - for (let z = 0; z < dimZ; z++) { - let sum = 0; - for (let k = 0; k < kernelSize; k++) { - const sampleZ = getFinalCoord(z + k - kernelCenter, dimZ); - const sampleIdx = baseOffset + sampleZ * strideZ; - sum += inputData[sampleIdx] * kernel[k]; - } - - outputData[baseOffset + z * strideZ] = sum; - } - } - } - } -} - -function gaussianFilter3D( - inputData: TypedArray | number[], - dimensions: number[], - sigmaPixels: [number, number, number], - radiusFactor = 1.5 -) { - 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 temp = new Float32Array(totalSize); - const output = new Float32Array(totalSize); - - convolve1D(inputData, output, dimensions, kernelX, 0); - convolve1D(output, temp, dimensions, kernelY, 1); - convolve1D(temp, output, dimensions, kernelZ, 2); - - return output; -} - -function calculateBoundingBox( - data: TypedArray | number[], - dimensions: number[], - label: number -) { - const [dimX, dimY, dimZ] = dimensions; - const bounds = [dimX, -1, dimY, -1, dimZ, -1]; - - for (let z = 0; z < dimZ; z++) { - for (let y = 0; y < dimY; y++) { - for (let x = 0; x < dimX; x++) { - const index = x + y * dimX + z * dimX * dimY; - if (data[index] === label) { - 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); - } - } - } - } - - if (bounds[1] === -1) return null; - - return bounds; -} - -function expandBoundingBox( - bounds: number[], - dimensions: number[], - sigmaPixels: [number, number, number], - radiusFactor = 1.5 -) { - const [dimX, dimY, dimZ] = dimensions; - const paddingX = Math.ceil(sigmaPixels[0] * radiusFactor); - const paddingY = Math.ceil(sigmaPixels[1] * radiusFactor); - const paddingZ = Math.ceil(sigmaPixels[2] * radiusFactor); - - return [ - Math.max(0, bounds[0] - paddingX), - Math.min(dimX - 1, bounds[1] + paddingX), - Math.max(0, bounds[2] - paddingY), - Math.min(dimY - 1, bounds[3] + paddingY), - Math.max(0, bounds[4] - paddingZ), - Math.min(dimZ - 1, bounds[5] + paddingZ), - ]; -} - -function extractSubVolume( - data: TypedArray | number[], - dimensions: number[], - bounds: number[] -) { - const [dimX, dimY] = dimensions; - const [minX, maxX, minY, maxY, minZ, maxZ] = bounds; - const subDimX = maxX - minX + 1; - const subDimY = maxY - minY + 1; - const subDimZ = maxZ - minZ + 1; - const subDims = [subDimX, subDimY, subDimZ]; - const subData = new Float32Array(subDimX * subDimY * subDimZ); - - let subIndex = 0; - for (let z = minZ; z <= maxZ; z++) { - for (let y = minY; y <= maxY; y++) { - for (let x = minX; x <= maxX; x++) { - const origIndex = x + y * dimX + z * dimX * dimY; - subData[subIndex] = data[origIndex] as number; - subIndex++; - } - } - } - - return { subData, subDims }; -} - -function copySubVolumeBack( - subData: Float32Array, - originalData: TypedArray | number[], - dimensions: number[], - bounds: number[], - label: number -) { - const [dimX, dimY] = dimensions; - const [minX, maxX, minY, maxY, minZ, maxZ] = bounds; - - let subIndex = 0; - for (let z = minZ; z <= maxZ; z++) { - for (let y = minY; y <= maxY; y++) { - for (let x = minX; x <= maxX; x++) { - const origIndex = x + y * dimX + z * dimX * dimY; - const origLabel = originalData[origIndex]; - const subValue = subData[subIndex]; - - if (origLabel === label || origLabel === 0) { - originalData[origIndex] = subValue > 127.5 ? label : 0; - } - subIndex++; - } - } - } -} - -function createBinaryMask(data: TypedArray | number[], label: number) { - const mask = new Float32Array(data.length); - for (let i = 0; i < data.length; i++) { - mask[i] = data[i] === label ? 255.0 : 0.0; - } - return mask; -} - -export function gaussianSmoothLabelMapWorker(input: { - data: TypedArray | number[]; - dimensions: number[]; - spacing: [number, number, number]; - params: { sigma: number; label: number }; -}) { - const { data: originalData, dimensions, spacing, params } = input; - const { sigma, label } = params; - - if (sigma <= 0) { - throw new Error('Sigma must be positive'); - } - - let originalLabelCount = 0; - for (let i = 0; i < originalData.length; i++) { - if (originalData[i] === label) { - originalLabelCount++; - } - } - - if (originalLabelCount === 0) { - const outputData = createTypedArrayLike(originalData, originalData.length); - for (let i = 0; i < originalData.length; i++) { - outputData[i] = originalData[i]; - } - return outputData; - } - - const sigmaPixels: [number, number, number] = [ - sigma / spacing[0], - sigma / spacing[1], - sigma / spacing[2], - ]; - - 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 outputData; - } - - const expandedBounds = expandBoundingBox( - bounds, - dimensions, - sigmaPixels, - 1.5 - ); - const { subData, subDims } = extractSubVolume( - originalData, - dimensions, - expandedBounds - ); - - const subBinaryMask = createBinaryMask(subData, label); - const smoothedSubMask = gaussianFilter3D( - subBinaryMask, - subDims, - sigmaPixels, - 1.5 - ); - - const outputData = createTypedArrayLike(originalData, originalData.length); - for (let i = 0; i < originalData.length; i++) { - outputData[i] = originalData[i]; - } - - copySubVolumeBack( - smoothedSubMask, - outputData, - dimensions, - expandedBounds, - label - ); - - return outputData; -} - -const workerApi = { - gaussianSmoothLabelMapWorker, -}; - -Comlink.expose(workerApi); diff --git a/src/core/tools/paint/index.ts b/src/core/tools/paint/index.ts index 48a184bae..82dbf2ed9 100644 --- a/src/core/tools/paint/index.ts +++ b/src/core/tools/paint/index.ts @@ -3,6 +3,7 @@ import vtkPaintWidget from '@/src/vtk/PaintWidget'; import type { Vector2 } from '@kitware/vtk.js/types'; import { vec3 } from 'gl-matrix'; import { Maybe } from '@/src/types'; +import type { Extent3D } from '@/src/segmentation/geometry'; import { IPaintBrush } from './brush'; import EllipsePaintBrush from './ellipse-brush'; @@ -12,6 +13,7 @@ export enum PaintMode { CirclePaint, Erase, Process, + Eyedropper, } export default class PaintTool { @@ -58,6 +60,44 @@ export default class PaintTool { this.brushValue = value; } + /** + * The index-space box a stroke can touch, in whatever space its points are + * given in. Bounded storage has to be grown to cover the stroke before the + * brush runs, and this states the region from the same stencil the brush + * writes through. + */ + strokeBounds( + sliceAxis: 0 | 1 | 2, + startPoint: vec3, + endPoint?: vec3 + ): Extent3D { + const round = (point: vec3) => [...point].map((value) => Math.round(value)); + const start = round(startPoint); + const end = endPoint ? round(endPoint) : [...start]; + + const { size } = this.brush.getStencil(); + const center = [ + Math.floor((size[0] - 1) / 2), + Math.floor((size[1] - 1) / 2), + ]; + + const bounds = [0, 0, 0, 0, 0, 0] as Extent3D; + bounds[sliceAxis * 2] = start[sliceAxis]; + bounds[sliceAxis * 2 + 1] = start[sliceAxis]; + [0, 1, 2] + .filter((axis) => axis !== sliceAxis) + .forEach((axis, planeIndex) => { + bounds[axis * 2] = + Math.min(start[axis], end[axis]) - center[planeIndex]; + bounds[axis * 2 + 1] = + Math.max(start[axis], end[axis]) + + size[planeIndex] - + 1 - + center[planeIndex]; + }); + return bounds; + } + /** * Adds paint to a labelmap. * @@ -71,19 +111,31 @@ export default class PaintTool { * @param startPoint start point * @param endPoint ending point (optional) */ + /** The value a stroke writes, or undefined when this mode does not brush. */ + private strokeValue() { + const inBrushingMode = + this.mode === PaintMode.CirclePaint || this.mode === PaintMode.Erase; + if (this.brushValue == null || !inBrushingMode) return undefined; + return this.mode === PaintMode.Erase ? ERASE_BRUSH_VALUE : this.brushValue; + } + paintLabelmap( labelmap: vtkLabelMap, sliceAxis: 0 | 1 | 2, startPoint: vec3, - endPoint?: vec3, - shouldPaint: (offset: number, point: number[]) => boolean = () => true + { + endPoint, + shouldPaint = () => true, + onPainted, + }: { + endPoint?: vec3; + shouldPaint?: (offset: number, point: number[]) => boolean; + onPainted?: (point: number[]) => void; + } = {} ) { - const inBrushingMode = - this.mode === PaintMode.CirclePaint || this.mode === PaintMode.Erase; - if (this.brushValue == null || !inBrushingMode) return; + const brushValue = this.strokeValue(); + if (brushValue === undefined) return; - const brushValue = - this.mode === PaintMode.Erase ? ERASE_BRUSH_VALUE : this.brushValue; const stencil = this.brush.getStencil(); const start = [ @@ -101,6 +153,7 @@ export default class PaintTool { end.splice(sliceAxis, 1); } + let changed = false; const labelmapPixels = labelmap.getPointData().getScalars().getData(); const labelmapDims = labelmap.getDimensions(); const jStride = labelmapDims[0]; @@ -122,6 +175,38 @@ export default class PaintTool { const point2 = [...end]; const rounded = [0, 0, 0]; const curPoint: number[] = [0, 0]; + + // Walks the line between the stencil's two stamps, one index at a time. + const paintLine = () => { + const dx = point2[0] - point1[0]; + const dy = point2[1] - point1[1]; + let steps = Math.abs(Math.abs(dx) > Math.abs(dy) ? dx : dy); + const incX = dx / steps; + const incY = dy / steps; + [curPoint[0], curPoint[1]] = point1; + while (steps-- >= 0) { + // add slice axis to make a proper 3D index + curPoint.splice(sliceAxis, 0, ijkSlice); + rounded[0] = Math.round(curPoint[0]); + rounded[1] = Math.round(curPoint[1]); + rounded[2] = Math.round(curPoint[2]); + + const offset = rounded[0] + rounded[1] * jStride + rounded[2] * kStride; + if (isInBounds(rounded) && shouldPaint(offset, rounded)) { + if (labelmapPixels[offset] !== brushValue) { + labelmapPixels[offset] = brushValue; + changed = true; + } + onPainted?.(rounded); + } + + // undo adding the slice axis value + curPoint.splice(sliceAxis, 1); + + curPoint[0] += incX; + curPoint[1] += incY; + } + }; for (let y = 0; y < size[1]; y++) { const ydelta = y - centerY; const yoffset = y * size[0]; @@ -133,37 +218,11 @@ export default class PaintTool { point1[1] = start[1] + ydelta; point2[0] = end[0] + xdelta; point2[1] = end[1] + ydelta; - - // line between the two points - const dx = point2[0] - point1[0]; - const dy = point2[1] - point1[1]; - let steps = Math.abs(Math.abs(dx) > Math.abs(dy) ? dx : dy); - const incX = dx / steps; - const incY = dy / steps; - [curPoint[0], curPoint[1]] = point1; - while (steps-- >= 0) { - // add slice axis to make a proper 3D index - curPoint.splice(sliceAxis, 0, ijkSlice); - rounded[0] = Math.round(curPoint[0]); - rounded[1] = Math.round(curPoint[1]); - rounded[2] = Math.round(curPoint[2]); - - const offset = - rounded[0] + rounded[1] * jStride + rounded[2] * kStride; - if (isInBounds(rounded) && shouldPaint(offset, rounded)) { - labelmapPixels[offset] = brushValue; - } - - // undo adding the slice axis value - curPoint.splice(sliceAxis, 1); - - curPoint[0] += incX; - curPoint[1] += incY; - } + paintLine(); } } } - labelmap.modified(); + if (changed) labelmap.modified(); } } diff --git a/src/core/vtk/useVtkView.ts b/src/core/vtk/useVtkView.ts index 3ff4e6aea..6a9060f15 100644 --- a/src/core/vtk/useVtkView.ts +++ b/src/core/vtk/useVtkView.ts @@ -79,10 +79,14 @@ export function useWidgetManager(renderer: vtkRenderer) { const updatePickingState = () => { const enabled = manager.getPickingEnabled(); - const widgetCount = manager.getWidgets().length; - if (!enabled && widgetCount) { + const widgets = manager.getWidgets(); + const focused = widgets.find((widget) => widget.hasFocus()); + const needsPicking = focused + ? focused.getNestedPickable() + : widgets.some((widget) => widget.getNestedPickable()); + if (!enabled && needsPicking) { manager.enablePicking(); - } else if (enabled && !widgetCount) { + } else if (enabled && !needsPicking) { manager.disablePicking(); } }; diff --git a/src/io/__tests__/segNrrdMetadata.spec.ts b/src/io/__tests__/segNrrdMetadata.spec.ts index d20258076..a75b06f49 100644 --- a/src/io/__tests__/segNrrdMetadata.spec.ts +++ b/src/io/__tests__/segNrrdMetadata.spec.ts @@ -8,55 +8,48 @@ import { type ParsedSegment, type DecodedSegment, } from '@/src/io/segNrrdMetadata'; -import type { SegmentGroupMetadata } from '@/src/store/segmentGroups'; +import type { LabelmapSegment } from '@/src/segmentation/model'; // Tests the metadata-embedding layer rather than the ITK-wasm write, which // needs a worker + wasm the unit env cannot run. The key gate: names/colors // are embedded only for the literal 'seg.nrrd' format token; plain 'nrrd' // silently drops them. -const metadata: SegmentGroupMetadata = { - name: 'Tumor group', - parentImage: 'img-1', - segments: { - order: [1, 2], - byValue: { - 1: { - value: 1, - name: 'Tumor', - color: [255, 0, 0, 255], - visible: true, - locked: false, - }, - 2: { - value: 2, - name: 'Edema', - color: [0, 128, 255, 255], - visible: true, - locked: false, - }, - }, +const segments: LabelmapSegment[] = [ + { + value: 1, + name: 'Tumor', + color: [255, 0, 0, 255], + visible: true, + locked: false, + }, + { + value: 2, + name: 'Edema', + color: [0, 128, 255, 255], + visible: true, + locked: false, }, -}; +]; const dims: [number, number, number] = [4, 4, 2]; describe('buildSegNrrdMetadata embeds segment names + colors', () => { it('writes a Name / Color / LabelValue entry per segment, in order', () => { - const m = buildSegNrrdMetadata(metadata, dims); + const m = buildSegNrrdMetadata(segments, dims); expect(m.get('Segment0_Name')).toBe('Tumor'); expect(m.get('Segment0_Color')).toBe('1.000000 0.000000 0.000000'); expect(m.get('Segment0_LabelValue')).toBe('1'); - // Segment 1 (label value 2) — 128/255 → 0.501961, 255/255 → 1. + // SegmentMask 1 (label value 2) — 128/255 → 0.501961, 255/255 → 1. expect(m.get('Segment1_Name')).toBe('Edema'); expect(m.get('Segment1_Color')).toBe('0.000000 0.501961 1.000000'); expect(m.get('Segment1_LabelValue')).toBe('2'); }); it('stamps the Slicer segmentation representation + extent from dimensions', () => { - const m = buildSegNrrdMetadata(metadata, dims); + const m = buildSegNrrdMetadata(segments, dims); expect(m.get('Segmentation_MasterRepresentation')).toBe('Binary labelmap'); // extent = 0..dim-1 per axis. expect(m.get('Segment0_Extent')).toBe('0 3 0 3 0 1'); @@ -65,7 +58,7 @@ describe('buildSegNrrdMetadata embeds segment names + colors', () => { describe('maybeBuildSegNrrdMetadata gates on the exact seg.nrrd token', () => { it('embeds names/colors ONLY for the literal "seg.nrrd" format', () => { - const m = maybeBuildSegNrrdMetadata('seg.nrrd', metadata, dims); + const m = maybeBuildSegNrrdMetadata('seg.nrrd', segments, dims); expect(m).toBeInstanceOf(Map); expect(m?.get('Segment0_Name')).toBe('Tumor'); expect(m?.get('Segment1_Name')).toBe('Edema'); @@ -74,9 +67,9 @@ describe('maybeBuildSegNrrdMetadata gates on the exact seg.nrrd token', () => { it('drops the metadata for any other token (the load-bearing gotcha)', () => { // Passing 'nrrd' (or 'nii.gz', 'vti', …) silently omits segment names/colors // — must serialize with 'seg.nrrd', never saveFormat's 'vti' default. - expect(maybeBuildSegNrrdMetadata('nrrd', metadata, dims)).toBeUndefined(); - expect(maybeBuildSegNrrdMetadata('nii.gz', metadata, dims)).toBeUndefined(); - expect(maybeBuildSegNrrdMetadata('vti', metadata, dims)).toBeUndefined(); + expect(maybeBuildSegNrrdMetadata('nrrd', segments, dims)).toBeUndefined(); + expect(maybeBuildSegNrrdMetadata('nii.gz', segments, dims)).toBeUndefined(); + expect(maybeBuildSegNrrdMetadata('vti', segments, dims)).toBeUndefined(); }); }); @@ -88,7 +81,7 @@ describe('maybeBuildSegNrrdMetadata gates on the exact seg.nrrd token', () => { describe('parseSegNrrdMetadata recovers segment descriptors from header metadata', () => { it('round-trips buildSegNrrdMetadata: names, label values, colors back to 0–255', () => { - const parsed = parseSegNrrdMetadata(buildSegNrrdMetadata(metadata, dims)); + const parsed = parseSegNrrdMetadata(buildSegNrrdMetadata(segments, dims)); expect(parsed).toEqual([ { value: 1, name: 'Tumor', color: [255, 0, 0, 255], visible: true }, // 0.501961 → round(0.501961*255) = 128; 1.000000 → 255. @@ -138,7 +131,7 @@ describe('parseSegNrrdMetadata recovers segment descriptors from header metadata it('recovers a segment past a header gap (no zero-based contiguity assumption)', () => { // A foreign / hand-edited header may leave gaps between indices. Every // present Segment{N}_ block must be recovered, not just the leading run. - const m = buildSegNrrdMetadata(metadata, dims); // Segment0, Segment1 + const m = buildSegNrrdMetadata(segments, dims); // Segment0, Segment1 m.set('Segment5_Name', 'orphan'); // gap at 2..4 — must still be reached m.set('Segment5_LabelValue', '9'); m.set('Segment5_Color', '0 0 0'); @@ -241,7 +234,7 @@ describe('overlaySegmentMetadata merges embedded metadata over the enumeration', }); it('does NOT duplicate an out-of-enumeration value described twice (dedup)', () => { - // A foreign / hand-edited header with two Segment blocks sharing a LabelValue + // A foreign / hand-edited header with two SegmentMask blocks sharing a LabelValue // outside the voxel range must yield ONE segment (last-wins), not two rows // with a colliding `order`/Vue `:key`. const described: ParsedSegment[] = [ diff --git a/src/io/dicom.ts b/src/io/dicom.ts index 2f9b1b441..79394a210 100644 --- a/src/io/dicom.ts +++ b/src/io/dicom.ts @@ -189,14 +189,14 @@ export async function readVolumeSlice( return result.outputs[0].data as Image; } -export type Segment = { +export type SegmentMask = { SegmentLabel: string; labelID: number; recommendedDisplayRGBValue: [number, number, number]; }; export type ReadOverlappingSegmentationMeta = { - segmentAttributes: Segment[][]; + segmentAttributes: SegmentMask[][]; }; type ReadOverlappingSegmentationResultWithRealMeta = @@ -204,7 +204,7 @@ type ReadOverlappingSegmentationResultWithRealMeta = metaInfo: ReadOverlappingSegmentationMeta; }; -export async function buildSegmentGroups(file: File) { +export async function readDicomSegmentation(file: File) { const inputImage = sanitizeFile(file); const result = (await readOverlappingSegmentation(inputImage, { webWorker: getWorker(), diff --git a/src/io/import/__tests__/configIo.spec.ts b/src/io/import/__tests__/configIo.spec.ts new file mode 100644 index 000000000..00bf85d8c --- /dev/null +++ b/src/io/import/__tests__/configIo.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { configIo } from '../configIo'; + +describe('segmentation filename configuration migration', () => { + it.each([ + [{}, ''], + [{ segmentationExtension: 'seg' }, 'seg'], + [{ segmentGroupExtension: 'seg' }, 'seg'], + [{ segmentationExtension: 'seg', segmentGroupExtension: 'seg' }, 'seg'], + [{ segmentationExtension: '' }, ''], + [{ segmentGroupExtension: '' }, ''], + [{ segmentationExtension: '', segmentGroupExtension: '' }, ''], + ])('normalizes %j to one runtime field', (input, extension) => { + expect(configIo.parse(input)).toEqual({ + layerExtension: '', + segmentationExtension: extension, + }); + }); + + it.each([ + { segmentationExtension: 'seg', segmentGroupExtension: 'mask' }, + { segmentationExtension: '', segmentGroupExtension: 'seg' }, + { segmentationExtension: 'seg', segmentGroupExtension: '' }, + ])('rejects conflicting aliases: %j', (input) => { + expect(() => configIo.parse(input)).toThrow( + 'io.segmentGroupExtension conflicts with io.segmentationExtension' + ); + }); + + it.each(['segmentGroupExtension', 'segmentationExtension'])( + 'rejects a non-string %s', + (key) => { + expect(configIo.safeParse({ [key]: null }).success).toBe(false); + expect(configIo.safeParse({ [key]: 1 }).success).toBe(false); + } + ); +}); diff --git a/src/io/import/__tests__/configJson.spec.ts b/src/io/import/__tests__/configJson.spec.ts index ac2ec03c8..dea6e2399 100644 --- a/src/io/import/__tests__/configJson.spec.ts +++ b/src/io/import/__tests__/configJson.spec.ts @@ -1,5 +1,21 @@ -import { describe, it, expect } from 'vitest'; -import { config } from '@/src/io/import/configJson'; +import { beforeEach, describe, it, expect } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { + applyPostStateConfig, + config, + recognizeConfig, +} from '@/src/io/import/configJson'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { MessageType, useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { useViewStore } from '@/src/store/views'; describe('config schema', () => { describe('shortcuts', () => { @@ -69,3 +85,231 @@ describe('config schema', () => { }); }); }); + +describe('segment type config', () => { + const seatAndView = (id: string) => { + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + useViewStore().setDataForAllViews(id); + }; + + const typeSummary = (registry: SegmentRegistry) => + registry.segmentList.value.map((type) => ({ + name: type.name, + color: registry.appearanceOf(type.id).cssColor, + })); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + // Config is applied before the primary selection, so there is no current + // image when the segments arrive. + it('applies the shared segments configured before an image loads', async () => { + applyPostStateConfig( + config.parse({ segments: { Tumor: { color: '#00ff00' } } }) + ); + + seatAndView('img-1'); + await nextTick(); + + expect(typeSummary(usePolygonStore().segments)).toEqual([ + { name: 'Tumor', color: '#00ff00' }, + ]); + expect(typeSummary(useRectangleStore().segments)).toEqual([ + { name: 'Tumor', color: '#00ff00' }, + ]); + }); + + // Functional CSS notation is not parsed. Falling back to black would read as + // a deliberate colour, so the file's own boundary says what it could not use. + it('reports a config color it cannot parse instead of blackening it', () => { + applyPostStateConfig( + config.parse({ + segments: { + Tumor: { color: 'rgb(214, 0, 0)' }, + Node: { color: 'nonsense' }, + Fine: { color: '#00ff00' }, + }, + }) + ); + + const [message] = useMessageStore().messages; + expect(message.type).toBe(MessageType.Error); + expect(message.title).toContain('Tumor (rgb(214, 0, 0))'); + expect(message.title).toContain('Node (nonsense)'); + expect(message.title).not.toContain('Fine'); + }); + + it('configures rulers out of the one segment section', async () => { + applyPostStateConfig( + config.parse({ segments: { Tumor: { color: '#00ff00' } } }) + ); + + seatAndView('img-1'); + await nextTick(); + + expect(typeSummary(useRulerStore().segments)).toEqual([ + { name: 'Tumor', color: '#00ff00' }, + ]); + expect(typeSummary(usePolygonStore().segments)).toEqual([ + { name: 'Tumor', color: '#00ff00' }, + ]); + expect(useRulerStore().segments.selectedSegmentId.value).toBe( + useRulerStore().segments.findSegmentByName('Tumor')?.id + ); + }); + + it('applies segments to an image that is already loaded', async () => { + seatAndView('img-1'); + await nextTick(); + + applyPostStateConfig( + config.parse({ segments: { Tumor: { color: '#00ff00' } } }) + ); + + expect(typeSummary(usePolygonStore().segments)).toEqual([ + { name: 'Tumor', color: '#00ff00' }, + ]); + }); + + it('offers the same segments on each image the user views', async () => { + applyPostStateConfig( + config.parse({ segments: { Tumor: { color: '#00ff00' } } }) + ); + + seatAndView('img-1'); + await nextTick(); + seatAndView('img-2'); + await nextTick(); + + expect(typeSummary(usePolygonStore().segments)).toEqual([ + { name: 'Tumor', color: '#00ff00' }, + ]); + // Offered, not minted: a configured type gets a mask on the first edit. + expect( + useSegmentationStore().getSegmentationForImage('img-1') + ).toBeUndefined(); + }); + + // The type carries the appearance, so one configured entry reaches paint, + // rectangles and polygons alike. + it('keeps the configured appearance on the type an edit lands in', async () => { + applyPostStateConfig( + config.parse({ + segments: { Tumor: { color: '#00ff00', strokeWidth: 9 } }, + }) + ); + seatAndView('img-1'); + await nextTick(); + + const polygons = usePolygonStore(); + const rectangles = useRectangleStore(); + const segmentId = polygons.segments.findSegmentByName('Tumor')!.id; + polygons.segments.selectSegment(segmentId); + const maskId = useSegmentationStore().resolveEditTarget('img-1'); + + expect(useSegmentationStore().getMask(maskId).segmentId).toBe(segmentId); + expect(polygons.segments.appearanceOf(segmentId)).toMatchObject({ + name: 'Tumor', + cssColor: '#00ff00', + strokeWidth: 9, + }); + expect(rectangles.segments.appearanceOf(segmentId).name).toBe('Tumor'); + }); + + it('creates nothing when no segments are configured', async () => { + applyPostStateConfig(config.parse({ segments: {} })); + + seatAndView('img-1'); + await nextTick(); + + expect(usePolygonStore().segments.segmentList.value).toEqual([]); + expect( + useSegmentationStore().getSegmentationForImage('img-1') + ).toBeUndefined(); + }); +}); + +describe('pre-7.0 labels', () => { + const typeSummary = () => + usePolygonStore().segments.segmentList.value.map((type) => ({ + name: type.name, + color: usePolygonStore().segments.appearanceOf(type.id).cssColor, + })); + + const applyLabels = (labels: unknown) => + applyPostStateConfig(config.parse({ labels })); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + // Only known top-level keys mark a file as config, so a config that names + // nothing but labels has to keep counting as one. + it('recognizes a config that carries only labels', async () => { + const recognized = await recognizeConfig({ + labels: { defaultLabels: { Tumor: { color: 'red' } } }, + }); + + expect(recognized.kind).toBe('config'); + }); + + it('reads every label record into the one registry', () => { + applyLabels({ + defaultLabels: { Tumor: { color: '#00ff00' } }, + rulerLabels: { 'Long axis': { color: '#0000ff' } }, + }); + + expect(typeSummary()).toEqual([ + { name: 'Long axis', color: '#0000ff' }, + { name: 'Tumor', color: '#00ff00' }, + ]); + }); + + it('carries a label stroke width onto its segment', () => { + applyLabels({ polygonLabels: { Tumor: { color: 'red', strokeWidth: 4 } } }); + + const registry = usePolygonStore().segments; + const segmentId = registry.findSegmentByName('Tumor')!.id; + expect(registry.appearanceOf(segmentId).strokeWidth).toBe(4); + }); + + it('gives a name several tools declared one segment', () => { + applyLabels({ + rulerLabels: { Tumor: { color: '#0000ff' } }, + polygonLabels: { Tumor: { color: '#00ff00' } }, + }); + + expect(typeSummary()).toEqual([{ name: 'Tumor', color: '#0000ff' }]); + }); + + it('lets a tool record outrank the default of the same name', () => { + applyLabels({ + defaultLabels: { Tumor: { color: '#00ff00' } }, + rectangleLabels: { Tumor: { color: '#0000ff' } }, + }); + + expect(typeSummary()).toEqual([{ name: 'Tumor', color: '#0000ff' }]); + }); + + it('keeps a rectangle label whose fill color has no segment equivalent', () => { + applyLabels({ + rectangleLabels: { Tumor: { color: '#00ff00', fillColor: '#ff000030' } }, + }); + + expect(typeSummary()).toEqual([{ name: 'Tumor', color: '#00ff00' }]); + }); + + it('leaves the labels of an already converted config alone', () => { + applyPostStateConfig( + config.parse({ + segments: { Lesion: { color: '#00ff00' } }, + labels: { defaultLabels: { Tumor: { color: '#0000ff' } } }, + }) + ); + + expect(typeSummary()).toEqual([{ name: 'Lesion', color: '#00ff00' }]); + }); +}); diff --git a/src/io/import/__tests__/configRecognition.spec.ts b/src/io/import/__tests__/configRecognition.spec.ts index 1d98c8836..26bebaa08 100644 --- a/src/io/import/__tests__/configRecognition.spec.ts +++ b/src/io/import/__tests__/configRecognition.spec.ts @@ -80,20 +80,20 @@ describe('config-by-shape recognition', () => { expect((await recognizeConfig({})).kind).toBe('data'); }); - // A mesh-shaped JSON carrying a `labels` key is classified as config. This is - // the deliberate forward-compat tradeoff: a known top-level section wins - // recognition even amid unknown keys, so the unknown keys are stripped rather - // than the whole config being dropped. + // A mesh-shaped JSON carrying a `segments` key is classified as config. + // This is the deliberate forward-compat tradeoff: a known top-level section + // wins recognition even amid unknown keys, so the unknown keys are stripped + // rather than the whole config being dropped. it('mixed JSON: applies the known section and strips the unknown top-level keys', async () => { const result = await recognizeConfig({ - labels: { defaultLabels: { tumor: { color: '#ff0000' } } }, + segments: { tumor: { color: '#ff0000' } }, vertices: [[0, 0, 0]], cells: [[0, 1, 2]], }); expect(result.kind).toBe('config'); if (result.kind === 'config') { - expect(result.config.labels).toEqual({ - defaultLabels: { tumor: { color: '#ff0000' } }, + expect(result.config.segments).toEqual({ + tumor: { color: '#ff0000' }, }); expect(result.ignoredKeys).toContain('vertices'); expect(result.ignoredKeys).toContain('cells'); diff --git a/src/io/import/__tests__/postStateLabels.spec.ts b/src/io/import/__tests__/postStateLabels.spec.ts new file mode 100644 index 000000000..f155786bf --- /dev/null +++ b/src/io/import/__tests__/postStateLabels.spec.ts @@ -0,0 +1,114 @@ +import { Skip } from '@/src/utils/evaluateChain'; +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { nextTick } from 'vue'; + +import { importDataSources } from '@/src/io/import/importDataSources'; +import { + recordingRestoreProcessors, + yieldsFor, +} from '@/src/io/import/__tests__/restoreProcessorFixtures'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; + +const sessionFile = () => + new File(['{}'], 'session.volview.json', { type: 'application/json' }); + +const configFile = () => + new File( + [ + JSON.stringify({ + segments: { + Configured: { color: '#0000ff' }, + }, + }), + ], + 'config.json', + { type: 'application/json' } + ); + +const RESTORED_MANIFEST = { + version: '7.0.0', + dataSources: [], + segments: [ + { + id: 'wire-restored', + name: 'Restored', + color: [0, 255, 0, 255] as [number, number, number, number], + visible: true, + locked: false, + }, + ], +}; + +const setup = yieldsFor((source) => + source.type === 'file' && source.file.name === 'session.volview.json' + ? { + type: 'stateFileSetup', + dataSources: [], + manifest: RESTORED_MANIFEST, + stateFiles: [], + missingFiles: [], + } + : Skip +); + +describe('post-state segment type config', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('applies configured segments after the restored registry', async () => { + let configWasVisibleDuringRestore = false; + const restore = recordingRestoreProcessors({ + setup, + completion: async () => { + const segmentIdMap = useSegmentStore().deserialize(RESTORED_MANIFEST); + configWasVisibleDuringRestore = + !!useSegmentStore().segments.findSegmentByName('Configured'); + useRectangleStore().deserializeTools( + { + tools: [ + { + imageID: 'img-1', + segmentId: 'wire-restored', + placing: false, + }, + ], + }, + { 'img-1': 'img-1' }, + segmentIdMap + ); + }, + }); + + await importDataSources( + [ + { + type: 'file', + file: sessionFile(), + fileType: 'application/json', + }, + { + type: 'file', + file: configFile(), + fileType: 'application/json', + }, + ], + restore.processors + ); + await nextTick(); + + // Restore seats its registry first; the config layers on top of it. + expect(configWasVisibleDuringRestore).toBe(false); + const rectangles = useRectangleStore(); + expect( + rectangles.segments.segmentList.value.map((type) => type.name) + ).toEqual(['Restored', 'Configured']); + const tool = rectangles.toolByID[rectangles.toolIDs[0]]; + expect(rectangles.appearanceOfTool(tool.id)).toMatchObject({ + name: 'Restored', + cssColor: '#00ff00', + }); + }); +}); diff --git a/src/io/import/__tests__/processingConfigInjection.spec.ts b/src/io/import/__tests__/processingConfigInjection.spec.ts index c24dfe231..7e698a9ae 100644 --- a/src/io/import/__tests__/processingConfigInjection.spec.ts +++ b/src/io/import/__tests__/processingConfigInjection.spec.ts @@ -208,11 +208,11 @@ describe('multiple configs merge at section granularity, last-wins (in-flight de const [ { applyPreStateConfig, config }, { useWindowingStore }, - { useSegmentGroupStore }, + { useSegmentationStore }, ] = await Promise.all([ import('@/src/io/import/configJson'), import('@/src/store/view-configs/windowing'), - import('@/src/store/segmentGroups'), + import('@/src/segmentation/store'), ]); // Config A sets windowing. @@ -232,7 +232,7 @@ describe('multiple configs merge at section granularity, last-wins (in-flight de level: 80, width: 800, }); - expect(useSegmentGroupStore().saveFormat).toBe('nrrd'); + expect(useSegmentationStore().saveFormat).toBe('nrrd'); }); }); diff --git a/src/io/import/__tests__/restoreStateFileLeaves.spec.ts b/src/io/import/__tests__/restoreStateFileLeaves.spec.ts index ce271cbfa..f480ffa19 100644 --- a/src/io/import/__tests__/restoreStateFileLeaves.spec.ts +++ b/src/io/import/__tests__/restoreStateFileLeaves.spec.ts @@ -3,49 +3,37 @@ import { setActivePinia, createPinia } from 'pinia'; import { restoreStateFile } from '@/src/io/import/processors/restoreStateFile'; import { leafStateId } from '@/src/io/import/dataSource'; import type { StateFileSetupResult } from '@/src/io/import/common'; +import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; // --------------------------------------------------------------------------- // Leaf preparation for composed manifests: a composed -// manifest's `datasets` covers base images only, so a segment group wired to -// an artifact uri entry via `dataSourceId` (no archive `path`) must get a -// synthesized dataset leaf — otherwise its dataIDMap key never materializes -// and restore hangs on the group forever. Unreferenced uri entries and -// path-carrying groups synthesize nothing. +// manifest's `datasets` covers base images only, so a segmentation artifact +// wired to an artifact uri entry via `dataSourceId` (no archive `path`) must +// get a synthesized dataset leaf; otherwise its dataIDMap key never +// materializes and restore hangs on the artifact forever. Unreferenced uri +// entries and path-carrying artifacts synthesize nothing. // --------------------------------------------------------------------------- const BASE_URI = 'volview-backend:base/ct-chest-001'; const ARTIFACT_URI = 'volview-backend:artifact/tumor-seg/v2'; const UNREFERENCED_URI = 'volview-backend:artifact/unreferenced/v1'; -const segmentGroup = (extras: Record) => ({ +const segmentationArtifact = (extras: Record) => ({ id: 'sg-tumor', + name: 'Tumor', + parentImage: 'ds-ct', ...extras, - metadata: { - name: 'Tumor', - parentImage: 'ds-ct', - segments: { - order: [1], - byValue: { - '1': { - value: 1, - name: 'Tumor', - color: [255, 0, 0, 255], - visible: true, - }, - }, - }, - }, }); const composedManifest = (extras: Record) => ({ - version: '6.4.0', + version: MANIFEST_VERSION, dataSources: [ { id: 1, type: 'uri', uri: BASE_URI, name: 'CT Chest' }, { id: 3, type: 'uri', uri: ARTIFACT_URI, name: 'Tumor.seg.nrrd' }, { id: 9, type: 'uri', uri: UNREFERENCED_URI, name: 'unreferenced.nrrd' }, ], datasets: [{ id: 'ds-ct', dataSourceId: 1 }], - segmentGroups: [segmentGroup(extras)], + segmentationArtifacts: [segmentationArtifact(extras)], }); async function prepareLeaves(manifest: Record) { @@ -67,21 +55,21 @@ type UriLeaf = { stateFileLeaf?: { stateID: string }; }; -describe('prepareLeafDataSources — composed-manifest segment groups', () => { +describe('prepareLeafDataSources: composed-manifest artifacts', () => { beforeEach(() => { setActivePinia(createPinia()); }); - it('synthesizes a leaf for the artifact a dataSourceId-only group references', async () => { + it('synthesizes a leaf for the artifact a dataSourceId-only entry references', async () => { const leaves = (await prepareLeaves( composedManifest({ dataSourceId: 3 }) )) as UriLeaf[]; const artifact = leaves.find((leaf) => leaf.uri === ARTIFACT_URI); expect(artifact).toBeDefined(); - // Keyed in the synthesized-leaf namespace so - // segmentGroups.deserialize finds it via dataIDMap[leafStateId(3)] and it - // can never collide with a scene-recorded dataset id. + // Keyed in the synthesized-leaf namespace so the segmentation store's + // deserialize finds it via dataIDMap[leafStateId(3)] and it can never + // collide with a scene-recorded dataset id. expect(artifact!.stateFileLeaf).toEqual({ stateID: leafStateId(3) }); // The base dataset leaf is untouched. @@ -92,7 +80,7 @@ describe('prepareLeafDataSources — composed-manifest segment groups', () => { expect(leaves.some((leaf) => leaf.uri === UNREFERENCED_URI)).toBe(false); }); - it('synthesizes nothing for a group whose bytes ride in the zip under path', async () => { + it('synthesizes nothing for an artifact whose bytes ride in the zip under path', async () => { const leaves = (await prepareLeaves( composedManifest({ dataSourceId: 3, diff --git a/src/io/import/__tests__/restoreStateIdCollision.spec.ts b/src/io/import/__tests__/restoreStateIdCollision.spec.ts index b321d3a6b..ac10e933d 100644 --- a/src/io/import/__tests__/restoreStateIdCollision.spec.ts +++ b/src/io/import/__tests__/restoreStateIdCollision.spec.ts @@ -1,13 +1,13 @@ +import { resolveLabelmapSources } from '@/src/io/import/labelmapImports'; +import { type Manifest } from '@/src/io/state-file/schema'; 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 { - restoreStateFile, - resolveArtifactRestoreSources, -} from '@/src/io/import/processors/restoreStateFile'; +import { restoreStateFile } from '@/src/io/import/processors/restoreStateFile'; import type { StateFileSetupResult } from '@/src/io/import/common'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useImageCacheStore } from '@/src/store/image-cache'; // --------------------------------------------------------------------------- @@ -26,17 +26,7 @@ import { useImageCacheStore } from '@/src/store/image-cache'; // unchanged). // --------------------------------------------------------------------------- -// `writeSegmentation` spawns a real Worker; keep the IO module out of the test. -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 artifactIO = { read: vi.fn(), write: vi.fn() }; const BASE_URI = 'volview-backend:base/ct-chest-001'; const ARTIFACT_URI = 'volview-backend:artifact/tumor-seg/v2'; @@ -127,10 +117,39 @@ const assembleStateIdMap = (leaves: UriLeaf[], completionOrder: string[]) => return { ...map, [leaf!.stateFileLeaf!.stateID]: storeIdByUri[uri] }; }, {}); +/** Restores a prepared manifest and hands back the mask it attached. */ +const restoreOnto = async ( + setup: { manifest: Manifest }, + stateFiles: Parameters< + ReturnType['deserialize'] + >[0]['stateFiles'], + dataIDMap: Record +) => { + const store = useSegmentationStore(); + const { restoredImportIds: restored } = await store.deserialize({ + manifest: setup.manifest, + stateFiles, + dataIDMap, + segmentIdMap: useSegmentStore().deserialize(setup.manifest), + labelmapSources: resolveLabelmapSources(setup.manifest), + io: artifactIO, + }); + const [maskId] = store.getSegmentationForImage(BASE_STORE_ID)!.order; + return { restored, maskId }; +}; + +/** The group attached, parented on the BASE dataset's store id. */ +const expectTumorOnBase = (restored: Set, maskId: string) => { + const store = useSegmentationStore(); + expect(restored.has('sg-tumor')).toBe(true); + expect(store.findMaskBinding(maskId)).toBeDefined(); + expect(store.segmentationOfMask(maskId)?.parentImageId).toBe(BASE_STORE_ID); +}; + describe('restore stateID namespaces (collision)', () => { beforeEach(() => { setActivePinia(createPinia()); - ioMocks.readImage.mockReset(); + artifactIO.read.mockReset(); }); it('mints disjoint stateIDs for a dataset and a leaf sharing the numeral', async () => { @@ -165,25 +184,20 @@ describe('restore stateID namespaces (collision)', () => { seatImage(BASE_STORE_ID, 'CT Chest', 0); seatImage(ARTIFACT_STORE_ID, 'Tumor.seg.nrrd', 1); - const store = useSegmentGroupStore(); - const { segmentGroupIDMap: idMap } = await store.deserialize( - setup.manifest, + const store = useSegmentationStore(); + const { restored, maskId } = await restoreOnto( + setup, [], - stateIDToStoreID, - resolveArtifactRestoreSources(setup.manifest) + stateIDToStoreID ); // The group attached, parented on the BASE dataset's store id. - const groupId = idMap['sg-tumor']; - expect(groupId).toBeDefined(); - expect(store.metadataByID[groupId].parentImage).toBe(BASE_STORE_ID); + expectTumorOnBase(restored, maskId); - // Its labelmap was built from the ARTIFACT's voxels, not the base's. - const scalars = store.dataIndex[groupId] - .getPointData() - .getScalars() - .getData() as Uint8Array; - expect(Array.from(new Set(scalars))).toEqual([1]); + // Its mask was built from the ARTIFACT's voxels, not the base's. + expect(Array.from(new Set(store.maskVoxels(maskId).scalars()))).toEqual([ + 1, + ]); // The base dataset survived; only the consumed temp dataset is gone. expect(imageCache.getVtkImageData(BASE_STORE_ID)).toBeTruthy(); @@ -197,7 +211,7 @@ describe('restore stateID namespaces (collision)', () => { // Those keys must keep working with no prefix (wire compat with every // existing saved scene). seatImage(BASE_STORE_ID, 'CT Chest', 0); - ioMocks.readImage.mockResolvedValue({ image: makeImage(7) }); + artifactIO.read.mockResolvedValue({ image: makeImage(7) }); const setup = await prepareLeaves({ version: '6.4.0', @@ -212,22 +226,18 @@ describe('restore stateID namespaces (collision)', () => { ], }); - const store = useSegmentGroupStore(); - const { segmentGroupIDMap: idMap } = await store.deserialize( - setup.manifest, + const { restored, maskId } = await restoreOnto( + setup, [ { archivePath: 'segmentations/Tumor.seg.nrrd', file: new File([''], 'Tumor.seg.nrrd'), }, ], - { '2': BASE_STORE_ID }, - resolveArtifactRestoreSources(setup.manifest) + { '2': BASE_STORE_ID } ); - const groupId = idMap['sg-tumor']; - expect(groupId).toBeDefined(); - expect(store.metadataByID[groupId].parentImage).toBe(BASE_STORE_ID); - expect(ioMocks.readImage).toHaveBeenCalledTimes(1); + expectTumorOnBase(restored, maskId); + expect(artifactIO.read).toHaveBeenCalledTimes(1); }); }); diff --git a/src/io/import/configIo.ts b/src/io/import/configIo.ts new file mode 100644 index 000000000..79c8edebf --- /dev/null +++ b/src/io/import/configIo.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +// Normalize before defaults so an explicitly empty legacy value still disables +// filename matching. Runtime consumers see only segmentationExtension. +export const configIo = z + .object({ + segmentGroupSaveFormat: z.string().optional(), + segmentationExtension: z.string().optional(), + segmentGroupExtension: z.string().optional(), + layerExtension: z.string().default(''), + }) + .refine( + (io) => + io.segmentGroupExtension === undefined || + io.segmentationExtension === undefined || + io.segmentGroupExtension === io.segmentationExtension, + { + path: ['segmentationExtension'], + message: + 'io.segmentGroupExtension conflicts with io.segmentationExtension. Use only io.segmentationExtension.', + } + ) + .transform(({ segmentGroupExtension, segmentationExtension, ...io }) => ({ + ...io, + segmentationExtension: segmentationExtension ?? segmentGroupExtension ?? '', + })); diff --git a/src/io/import/configJson.ts b/src/io/import/configJson.ts index e24bc0023..6301c9f40 100644 --- a/src/io/import/configJson.ts +++ b/src/io/import/configJson.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { configIo } from '@/src/io/import/configIo'; import { getEntries, isRecord, @@ -9,9 +10,9 @@ import { import { ACTIONS } from '@/src/constants'; import type { Action, Binding } from '@/src/constants'; -import { useRectangleStore } from '@/src/store/tools/rectangles'; -import { useRulerStore } from '@/src/store/tools/rulers'; -import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { tryCssColorToRGBA } from '@/src/segmentation/color'; import { useViewStore } from '@/src/store/views'; import { useWindowingStore } from '@/src/store/view-configs/windowing'; import { @@ -20,8 +21,7 @@ import { isDispatchable, } from '@/src/composables/useKeyboardShortcuts'; import { surfaceWarning } from '@/src/store/messages'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; +import { useSegmentationStore } from '@/src/segmentation/store'; import useLoadDataStore from '@/src/store/load-data'; import { layoutConfig } from '@/src/utils/layoutParsing'; @@ -40,47 +40,50 @@ const shortcuts = z .optional(); // -------------------------------------------------------------------------- -// Labels +// SegmentMask types + +// Every appearance field is optional and absent means the app default, so a +// configured segment states only what it changes. +const segment = z.object({ + color: z.string().optional(), + fillOpacity: z.number().optional(), + outlineOpacity: z.number().optional(), + strokeWidth: z.number().optional(), +}); + +// Keyed by name. Omitted leaves the registry alone; an empty record or null +// clears what an earlier config contributed. +const segmentRecord = z.record(z.string(), segment).or(z.null()).optional(); -const color = z.string(); +const segments = segmentRecord; -const label = z.object({ - color, +// Pre-7.0 configs named one label record per tool, plus a fallback record. +// The four describe the one registry now, so they read as `segments`. A +// rectangle label's `fillColor` belongs to the rectangle rather than to the +// segment and is dropped. +const legacyLabel = z.object({ + color: z.string(), strokeWidth: z.number().optional(), }); -const rulerLabel = label; -const polygonLabel = label; - -const rectangleLabel = z.intersection( - label, - z.object({ - fillColor: color, - }) -); +const legacyLabelRecord = z + .record(z.string(), legacyLabel) + .or(z.null()) + .optional(); const labels = z .object({ - defaultLabels: z.record(z.string(), label).or(z.null()).optional(), - rulerLabels: z.record(z.string(), rulerLabel).or(z.null()).optional(), - rectangleLabels: z - .record(z.string(), rectangleLabel) - .or(z.null()) - .optional(), - polygonLabels: z.record(z.string(), polygonLabel).or(z.null()).optional(), + defaultLabels: legacyLabelRecord, + rulerLabels: legacyLabelRecord, + rectangleLabels: legacyLabelRecord, + polygonLabels: legacyLabelRecord, }) .optional(); // -------------------------------------------------------------------------- // IO -const io = z - .object({ - segmentGroupSaveFormat: z.string().optional(), - segmentGroupExtension: z.string().default(''), - layerExtension: z.string().default(''), - }) - .optional(); +const io = configIo.optional(); // -------------------------------------------------------------------------- // Window Level @@ -96,6 +99,7 @@ const disabledViewTypes = z.array(z.enum(['2D', '3D', 'Oblique'])).optional(); export const config = z.object({ layouts, + segments, labels, shortcuts, io, @@ -124,7 +128,13 @@ export type Config = z.infer; export type ConfigRecognition = // `ignoredKeys` lists the unknown top-level keys that were stripped (empty // when every top-level key was a known section). - { kind: 'config'; config: Config; ignoredKeys: string[] } | { kind: 'data' }; + | { + kind: 'config'; + config: Config; + ignoredKeys: string[]; + deprecatedKeys: string[]; + } + | { kind: 'data' }; // --------------------------------------------------------------------------- // Config-section registry @@ -179,7 +189,16 @@ export const recognizeConfig = async ( // `fullConfig.parse` relies on zod's default (non-strict) object behavior to // drop unknown keys; adding `.strict()` would silently break forward-compat. const ignoredKeys = presentKeys.filter((key) => !knownKeys.has(key)); - return { kind: 'config', config: fullConfig.parse(raw), ignoredKeys }; + const deprecatedKeys = + isRecord(raw.io) && raw.io.segmentGroupExtension !== undefined + ? ['io.segmentGroupExtension'] + : []; + return { + kind: 'config', + config: fullConfig.parse(raw), + ignoredKeys, + deprecatedKeys, + }; }; export const recognizeConfigFile = async ( @@ -188,29 +207,59 @@ export const recognizeConfigFile = async ( return recognizeConfig(JSON.parse(await file.text())); }; -const applyLabels = (manifest: Config) => { - if (!manifest.labels) return; +// One registry backs every tool, so a name in more than one record is one +// segment and the record that declares it first sets its appearance, as a +// migrated session resolves it. `defaultLabels` is read last because it stood +// in only for the tools that declared no record of their own. +const segmentsFromLabels = (legacy: NonNullable) => + [ + legacy.rulerLabels, + legacy.rectangleLabels, + legacy.polygonLabels, + legacy.defaultLabels, + ].reduce>( + (merged, record) => ({ + ...merged, + ...Object.fromEntries( + Object.entries(record ?? {}).filter(([name]) => !(name in merged)) + ), + }), + {} + ); - // pass through null labels, use fallback labels if undefined - const defaultLabelsIfUndefined = (toolLabels: T) => { - if (toolLabels === undefined) return manifest.labels?.defaultLabels; - return toolLabels; - }; +// `segments` states the whole registry, so a config carrying both has been +// converted and the legacy section is spent. +const configuredSegments = (manifest: Config) => { + if (manifest.segments !== undefined) return manifest.segments; + if (manifest.labels === undefined) return undefined; + return segmentsFromLabels(manifest.labels); +}; - const applyLabelsToStore = ( - store: AnnotationToolStore, - maybeLabels: (typeof manifest.labels)[keyof typeof manifest.labels] - ) => { - const labelsOrFallback = defaultLabelsIfUndefined(maybeLabels); - if (!labelsOrFallback) return; - store.clearDefaultLabels(); - store.mergeLabels(labelsOrFallback); - }; +// A colour the parser does not know would otherwise resolve to opaque black, +// which reads as a deliberate choice. Reported here, at the boundary that owns +// the file, naming the segment and what it said. +const reportUnparseableColors = ( + configured: NonNullable +) => { + const bad = Object.entries(configured).flatMap(([name, props]) => + props.color && tryCssColorToRGBA(props.color) === undefined + ? [`${name} (${props.color})`] + : [] + ); + if (bad.length === 0) return; + useMessageStore().addError( + `Unrecognized ${plural(bad.length, 'color')} in config: ${bad.join(', ')}. ` + + 'Use a hex value such as #d60000, or a CSS color keyword.' + ); +}; - const { rulerLabels, rectangleLabels, polygonLabels } = manifest.labels; - applyLabelsToStore(useRulerStore(), rulerLabels); - applyLabelsToStore(useRectangleStore(), rectangleLabels); - applyLabelsToStore(usePolygonStore(), polygonLabels); +// An omitted section leaves the registry alone; an empty record or null +// clears what an earlier config contributed to it. +const applySegments = (manifest: Config) => { + const configured = configuredSegments(manifest); + if (configured === undefined) return; + if (configured) reportUnparseableColors(configured); + useSegmentStore().segments.replaceConfigSegments(configured); }; const applyLayout = (manifest: Config) => { @@ -270,9 +319,9 @@ const applyIo = (manifest: Config) => { if (!manifest.io) return; if (manifest.io.segmentGroupSaveFormat) - useSegmentGroupStore().saveFormat = manifest.io.segmentGroupSaveFormat; + useSegmentationStore().saveFormat = manifest.io.segmentGroupSaveFormat; const loadDataStore = useLoadDataStore(); - loadDataStore.segmentGroupExtension = manifest.io.segmentGroupExtension; + loadDataStore.segmentationExtension = manifest.io.segmentationExtension; loadDataStore.layerExtension = manifest.io.layerExtension; }; @@ -306,5 +355,5 @@ export const applyPreStateConfig = async (manifest: Config) => { }; export const applyPostStateConfig = (manifest: Config) => { - applyLabels(manifest); + applySegments(manifest); }; diff --git a/src/io/import/dataSource.ts b/src/io/import/dataSource.ts index 906796ffc..8f4c3d29b 100644 --- a/src/io/import/dataSource.ts +++ b/src/io/import/dataSource.ts @@ -59,7 +59,7 @@ export type StateFileLeaf = { }; /** - * Namespaces a synthesized segment-group leaf's stateID so it can't collide + * Namespaces a synthesized segmentation leaf's stateID so it can't collide * with a save-time dataset id in the shared restore `dataIDMap` — both are * small integers minted independently, and a bare `String(dataSourceId)` would * let leaf-completion order decide the winner. Transient only; nothing @@ -87,7 +87,7 @@ export type DataSource = { * ephemeral compose emits one dataset per FILE while the client merges them * into one volume — the restore accounting must map every per-file stateID * to that one result (mapping only the first - * member leaves N-1 datasets "unresolved" and makes segment-group parent + * member leaves N-1 datasets "unresolved" and makes segmentation parent * binding completion-order luck). */ export function findStateFileLeaves(dataSource: DataSource): StateFileLeaf[] { diff --git a/src/io/import/importDataSources.ts b/src/io/import/importDataSources.ts index 44acb1ba6..e1a3a2b69 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -159,7 +159,7 @@ async function importDataSourcesWithPolicy( const applicationHandlers = policy === 'application' - ? [handleConfig, restore.restoreStateFile, handleRemoteManifest] + ? [restore.restoreStateFile, handleConfig, handleRemoteManifest] : []; const handlers = [ @@ -243,8 +243,6 @@ async function importDataSourcesWithPolicy( cleanup(); - results.push(...applyConfigsPostState(configResults)); - const dicomChunkSources = chunkSources.filter( (src): src is ChunkSource => src.type === 'chunk' && src.mime === FILE_EXT_TO_MIME.dcm @@ -314,6 +312,8 @@ async function importDataSourcesWithPolicy( } } + results.push(...applyConfigsPostState(configResults)); + // A failed state-file leaf is already counted in the restore's consolidated // missing-content notice, so this layer owns its reporting: it returns as an // accounted-for 'ok' result, never as an error. An 'error' result in the diff --git a/src/io/import/labelmapImports.ts b/src/io/import/labelmapImports.ts new file mode 100644 index 000000000..ef6460c42 --- /dev/null +++ b/src/io/import/labelmapImports.ts @@ -0,0 +1,112 @@ +import { + manifestDatasets, + type Manifest, + type Segmentation, +} from '@/src/io/state-file/schema'; +import { leafStateId } from '@/src/io/import/dataSource'; +import type { ProcessingResultSource } from '@/src/types'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +/** A whole labelmap to split, with optional assignments to existing masks. */ +export type LabelmapImport = { + id: string; + parentImage: string; + name: string; + input: { path: string } | { dataSourceId: number }; + source?: ProcessingResultSource; + masks: Array<{ maskId: string; value: number }>; + decode: boolean; + activeValue?: number; + display: { + fillOpacity?: number; + outlineOpacity?: number; + visible?: boolean; + }; +}; + +/** Translate the incoming wire contract once; masks never retain import references. */ +export function planLabelmapImports(manifest: Manifest) { + const assignments = new Map(); + const segmentations: Segmentation[] = (manifest.segmentations ?? []).map( + (wire) => ({ + ...wire, + masks: wire.masks.map((mask) => { + const binding = mask.representations.labelmap; + if (binding?.artifactId !== undefined) { + const masks = assignments.get(binding.artifactId) ?? []; + masks.push({ + maskId: mask.id, + value: binding.sourceValue ?? SEGMENT_VALUE, + }); + assignments.set(binding.artifactId, masks); + } + return { + ...mask, + representations: + binding?.path === undefined + ? {} + : { + labelmap: { + path: binding.path, + extent: binding.extent, + name: binding.name, + source: binding.source, + }, + }, + }; + }), + }) + ); + const imports: LabelmapImport[] = (manifest.segmentationArtifacts ?? []).map( + (entry) => { + const masks = assignments.get(entry.id) ?? []; + return { + id: entry.id, + parentImage: entry.parentImage, + name: entry.name, + input: + entry.path !== undefined + ? { path: entry.path } + : { dataSourceId: entry.dataSourceId! }, + source: entry.source, + masks, + decode: entry.pendingDecode === true || masks.length === 0, + activeValue: entry.pendingActiveValue, + display: { + fillOpacity: entry.pendingFillOpacity, + outlineOpacity: entry.pendingOutlineOpacity, + visible: entry.pendingVisibility, + }, + }; + } + ); + return { segmentations, imports }; +} + +export type LabelmapRestoreSource = { stateId: string; temporary: boolean }; + +/** Sources already loaded as datasets are borrowed; other inputs are owned by restore. */ +export function planLabelmapSources(manifest: Manifest) { + const datasetBySource = new Map( + manifestDatasets(manifest).map((ds) => [ds.dataSourceId, ds.id]) + ); + const sourceIds = new Set(manifest.dataSources.map((source) => source.id)); + const leaves = new Map(); + const sources: Record = {}; + planLabelmapImports(manifest).imports.forEach((item) => { + if (!('dataSourceId' in item.input)) return; + const { dataSourceId } = item.input; + if (!sourceIds.has(dataSourceId)) return; + const datasetId = datasetBySource.get(dataSourceId); + const stateId = datasetId ?? leafStateId(dataSourceId); + if (datasetId === undefined) leaves.set(dataSourceId, stateId); + sources[item.id] = { + stateId, + temporary: datasetId === undefined || manifest.datasets === undefined, + }; + }); + return { leaves, sources }; +} + +export const resolveLabelmapSources = (manifest: Manifest) => + planLabelmapSources(manifest).sources; diff --git a/src/io/import/processors/handleConfig.ts b/src/io/import/processors/handleConfig.ts index 79ec4bca7..4383b0356 100644 --- a/src/io/import/processors/handleConfig.ts +++ b/src/io/import/processors/handleConfig.ts @@ -37,13 +37,22 @@ const handleConfig: ImportHandler = async (dataSource) => { if (recognition.ignoredKeys.length > 0) { surfaceIgnoredConfigKeys(recognition.ignoredKeys); } + if (recognition.deprecatedKeys.length > 0) { + surfaceWarning( + 'Deprecated configuration', + 'io.segmentGroupExtension was migrated to io.segmentationExtension. Update your configuration to use io.segmentationExtension.' + ); + } return asConfigResult(dataSource, recognition.config); } return Skip; } catch (err) { - throw new Error('Failed to parse config file', { - cause: ensureError(err), - }); + throw new Error( + `Failed to parse config file: ${ensureError(err).message}`, + { + cause: ensureError(err), + } + ); } }; diff --git a/src/io/import/processors/restoreStateFile.ts b/src/io/import/processors/restoreStateFile.ts index 82f882ff2..4427c4c36 100644 --- a/src/io/import/processors/restoreStateFile.ts +++ b/src/io/import/processors/restoreStateFile.ts @@ -12,8 +12,16 @@ import { import { MANIFEST, isStateFile } from '@/src/io/state-file/serialize'; import { partition, getURLBasename } from '@/src/utils'; import { basename } from '@/src/utils/path'; -import { leafStateId } from '@/src/io/import/dataSource'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { + dataSourcesById, + summarizeDataSource, +} from '@/src/io/state-file/dataSourceDisplayName'; +import { + planLabelmapSources, + resolveLabelmapSources, +} from '@/src/io/import/labelmapImports'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useToolStore } from '@/src/store/tools'; import { useLayersStore } from '@/src/store/datasets-layers'; import { extractFilesFromZip } from '@/src/io/zip'; @@ -87,111 +95,8 @@ function resolveToLeafSources( } } -const dataSourcesById = (manifest: Manifest): Record => - Object.fromEntries(manifest.dataSources.map((ds) => [ds.id, ds])); - -const dataSourceDisplayNames = ( - id: number, - byId: Record, - datasetFilePath: Record | undefined, - visiting = new Set() -): string[] => { - if (visiting.has(id)) return []; - const src = byId[id]; - if (!src) return []; - - const nextVisiting = new Set(visiting).add(id); - if (src.type === 'uri') { - return [src.name ?? getURLBasename(src.uri) ?? src.uri]; - } - if (src.type === 'file') { - const path = datasetFilePath?.[src.fileId]; - return path ? [basename(path)] : []; - } - if (src.type === 'archive') return [basename(src.path)]; - return src.sources.flatMap((sourceId) => - dataSourceDisplayNames(sourceId, byId, datasetFilePath, nextVisiting) - ); -}; - -const summarizeDataSource = ( - id: number, - byId: Record, - datasetFilePath: Record | undefined, - fallback: string -): string => { - const names = [...new Set(dataSourceDisplayNames(id, byId, datasetFilePath))]; - if (names.length === 0) return fallback; - if (names.length <= 3) return names.join(', '); - return `${names.slice(0, 2).join(', ')}, … (${names.length} files)`; -}; - -// A composed manifest's `datasets` covers base images only; a segment group -// wired to a uri entry via `dataSourceId` (and carrying no archive `path`) -// still needs its artifact fetched, or the group's dataIDMap key never -// materializes and restore hangs. The synthesized stateID is -// `leafStateId(dataSourceId)`, never the bare numeral: dataset ids and -// dataSourceIds are both small integers in real saves, and a shared key would -// hand the restore to leaf completion order. -const syntheticLeafSources = (manifest: Manifest): Map => { - const byId = dataSourcesById(manifest); - const coveredSourceIds = new Set( - manifestDatasets(manifest).map((ds) => ds.dataSourceId) - ); - const referencedLeafSourceIds = new Set( - (manifest.segmentGroups ?? []) - .filter((sg) => sg.path === undefined && sg.dataSourceId !== undefined) - .map((sg) => sg.dataSourceId!) - ); - return new Map( - [...referencedLeafSourceIds] - .filter((id) => !coveredSourceIds.has(id) && byId[id]?.type === 'uri') - .map((id) => [id, leafStateId(id)]) - ); -}; - -export type ArtifactRestoreSource = { - stateId: string; - temporary: boolean; -}; - -// Each path-less segment group's artifact source: the synthesized temporary -// leaf when one was minted, else the dataset covering that source. Explicitly -// carry ownership so cleanup never removes a real dataset merely because a -// group shares its dataSourceId. Legacy manifests have no dataset/artifact -// distinction, so their path-less artifact datasets retain the consumed-temp -// behavior used before `datasets` was added. -export const resolveArtifactRestoreSources = ( - manifest: Manifest -): Record => { - const minted = syntheticLeafSources(manifest); - const datasetIdBySourceId = new Map( - manifestDatasets(manifest).map((ds) => [ds.dataSourceId, ds.id]) - ); - return Object.fromEntries( - (manifest.segmentGroups ?? []).flatMap((sg) => { - if (sg.path !== undefined || sg.dataSourceId === undefined) return []; - const mintedStateId = minted.get(sg.dataSourceId); - const stateId = mintedStateId ?? datasetIdBySourceId.get(sg.dataSourceId); - return stateId !== undefined - ? [ - [ - sg.id, - { - stateId, - temporary: - mintedStateId !== undefined || - manifest.datasets === undefined, - }, - ] as const, - ] - : []; - }) - ); -}; - function prepareLeafDataSources(manifest: Manifest, datasetFiles: FileEntry[]) { - const byId = dataSourcesById(manifest); + const byId = dataSourcesById(manifest.dataSources); const pathToFile: Record = Object.fromEntries( datasetFiles.map((f) => [f.archivePath, f.file]) @@ -199,12 +104,12 @@ function prepareLeafDataSources(manifest: Manifest, datasetFiles: FileEntry[]) { const datasets = manifestDatasets(manifest); - const segmentGroupLeaves = [...syntheticLeafSources(manifest).entries()].map( + const importLeaves = [...planLabelmapSources(manifest).leaves.entries()].map( ([dataSourceId, stateId]) => ({ id: stateId, dataSourceId }) ); const missingFiles: Array<{ stateID: string; path: string }> = []; - const dataSources = [...datasets, ...segmentGroupLeaves].flatMap((ds) => { + const dataSources = [...datasets, ...importLeaves].flatMap((ds) => { const sources = resolveToLeafSources( ds.dataSourceId, byId, @@ -238,7 +143,7 @@ export async function completeStateFileRestore( failedLeaves: Array<{ stateID: string; name: string }> = [] ) { const viewStore = useViewStore(); - const byId = dataSourcesById(manifest); + const byId = dataSourcesById(manifest.dataSources); const datasets = manifestDatasets(manifest); const resolvedDatasets = datasets.filter((ds) => ds.id in stateIDToStoreID); const unresolvedDatasets = datasets.filter( @@ -279,18 +184,22 @@ export async function completeStateFileRestore( useViewConfigStore().deserializeAll(manifest, stateIDToStoreID); - const segmentGroupStore = useSegmentGroupStore(); - const { segmentGroupIDMap, skipped: skippedSegmentGroups } = - await segmentGroupStore.deserialize( + // Registries first: masks and shapes both name a type, and the + // ids they name are minted here. + const segmentIdMap = useSegmentStore().deserialize(manifest); + + const { skipped: skippedLabelmaps } = + await useSegmentationStore().deserialize({ manifest, stateFiles, - stateIDToStoreID, - resolveArtifactRestoreSources(manifest) - ); + dataIDMap: stateIDToStoreID, + segmentIdMap, + labelmapSources: resolveLabelmapSources(manifest), + }); useLayersStore().deserialize(manifest, stateIDToStoreID); - useToolStore().deserialize(manifest, segmentGroupIDMap, stateIDToStoreID); + useToolStore().deserialize(manifest, segmentIdMap, stateIDToStoreID); const missingBases = unresolvedDatasets.map((ds) => summarizeDataSource( @@ -330,8 +239,8 @@ export async function completeStateFileRestore( ...missingBases.map((name) => `- image: ${name}`), ...missingMembers, ...failedMembers, - ...skippedSegmentGroups.map( - ({ name, reason }) => `- segment group: ${name} (${reason})` + ...skippedLabelmaps.map( + ({ name, reason }) => `- segmentation: ${name} (${reason})` ), ]; if (missing.length > 0) { diff --git a/src/io/readWriteImage.ts b/src/io/readWriteImage.ts index 80d10fb81..0b652f474 100644 --- a/src/io/readWriteImage.ts +++ b/src/io/readWriteImage.ts @@ -7,7 +7,7 @@ import { } from '@itk-wasm/image-io'; import { vtiReader, vtiWriter } from '@/src/io/vtk/async'; import { getWorker } from '@/src/io/itk/worker'; -import type { SegmentGroupMetadata } from '@/src/store/segmentGroups'; +import type { LabelmapSegment } from '@/src/segmentation/model'; import { maybeBuildSegNrrdMetadata } from '@/src/io/segNrrdMetadata'; export type ReadImageResult = { @@ -63,11 +63,11 @@ export const writeImage = async ( export const writeSegmentation = ( format: string, image: vtkImageData, - segMetadata: SegmentGroupMetadata + segments: LabelmapSegment[] ) => { const metadata = maybeBuildSegNrrdMetadata( format, - segMetadata, + segments, image.getDimensions() as [number, number, number] ); return writeImage(format, image, metadata); diff --git a/src/io/resample/__tests__/reorientLabelImage.spec.ts b/src/io/resample/__tests__/reorientLabelImage.spec.ts new file mode 100644 index 000000000..df79b6a03 --- /dev/null +++ b/src/io/resample/__tests__/reorientLabelImage.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import { reorientLabelImage } from '../reorientLabelImage'; + +const image = (dimensions: [number, number, number]) => { + const result = vtkImageData.newInstance(); + result.setDimensions(dimensions); + result.getPointData().setScalars( + vtkDataArray.newInstance({ + values: Uint16Array.from( + { length: dimensions.reduce((a, b) => a * b, 1) }, + (_, i) => i + 1 + ), + }) + ); + return result; +}; + +describe('label-grid reorientation', () => { + it.each([0, 1, 2, 3, 4, 5, 6, 7])( + 'preserves every label under axis flips %i', + (flips) => { + const source = image([3, 4, 5]); + const target = image([3, 4, 5]); + const direction: [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + ] = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + const origin: [number, number, number] = [0, 0, 0]; + [3, 4, 5].forEach((size, axis) => { + if (flips & (1 << axis)) { + direction[axis * 4] = -1; + origin[axis] = size - 1; + } + }); + source.setDirection(direction); + source.setOrigin(origin); + const output = reorientLabelImage(target, source)!; + const values = output.getPointData().getScalars().getData(); + for (let k = 0; k < 5; k++) + for (let j = 0; j < 4; j++) + for (let i = 0; i < 3; i++) { + const x = flips & 1 ? 2 - i : i, + y = flips & 2 ? 3 - j : j, + z = flips & 4 ? 4 - k : k; + expect(values[i + 3 * (j + 4 * k)]).toBe(1 + x + 3 * (y + 4 * z)); + } + expect(output.getDirection()).toEqual(target.getDirection()); + expect(source.getPointData().getScalars().getData()[0]).toBe(1); + } + ); + + it('permutes unequal axes', () => { + const source = image([3, 4, 5]); + const target = image([4, 3, 5]); + target.setDirection([0, 1, 0, 1, 0, 0, 0, 0, 1]); + const output = reorientLabelImage(target, source)!; + expect(output.getExtent()).toEqual(target.getExtent()); + const values = output.getPointData().getScalars().getData(); + for (let k = 0; k < 5; k++) + for (let j = 0; j < 3; j++) + for (let i = 0; i < 4; i++) + expect(values[i + 4 * (j + 3 * k)]).toBe(1 + j + 3 * (i + 4 * k)); + }); + + it('defers nonzero extents to the general path', () => { + const source = image([3, 4, 5]); + source.setExtent(1, 3, 2, 5, 3, 7); + expect(reorientLabelImage(source, source)).toBeNull(); + }); + + it('accepts DICOM precision differences without changing labels', () => { + const source = image([3, 4, 5]); + const target = image([3, 4, 5]); + source.setOrigin([0.000004, 0.000004, 0.000012]); + expect([ + ...reorientLabelImage(target, source)! + .getPointData() + .getScalars() + .getData(), + ]).toEqual([...source.getPointData().getScalars().getData()]); + }); + + it('returns the source itself when it already sits on the target grid', () => { + const spacing: [number, number, number] = [0.7, 0.7, 3]; + const origin: [number, number, number] = [-120.1, -98.4, 33.7]; + const source = image([3, 4, 5]); + const target = image([3, 4, 5]); + [source, target].forEach((im) => { + im.setSpacing(spacing); + im.setOrigin(origin); + }); + expect(reorientLabelImage(target, source)).toBe(source); + + // The same geometry laid out along a flipped axis is a different grid and + // still has to go through the reslice. + const flipped = image([3, 4, 5]); + flipped.setSpacing(spacing); + flipped.setOrigin([origin[0] + spacing[0] * 2, origin[1], origin[2]]); + flipped.setDirection([-1, 0, 0, 0, 1, 0, 0, 0, 1]); + const output = reorientLabelImage(target, flipped)!; + expect(output).not.toBe(flipped); + const values = output.getPointData().getScalars().getData(); + for (let k = 0; k < 5; k++) + for (let j = 0; j < 4; j++) + for (let i = 0; i < 3; i++) + expect(values[i + 3 * (j + 4 * k)]).toBe(1 + (2 - i) + 3 * (j + 4 * k)); + }); + + it('defers fractional shifts, different sampling, and cropping to interpolation', () => { + const source = image([3, 4, 5]); + const target = image([3, 4, 5]); + source.setOrigin([0.25, 0, 0]); + expect(reorientLabelImage(target, source)).toBeNull(); + source.setOrigin([0, 0, 0]); + source.setSpacing([0.5, 1, 1]); + expect(reorientLabelImage(target, source)).toBeNull(); + source.setSpacing([1, 1, 1]); + expect(reorientLabelImage(image([1, 4, 5]), source)).toBeNull(); + }); +}); diff --git a/src/io/resample/reorientLabelImage.ts b/src/io/resample/reorientLabelImage.ts new file mode 100644 index 000000000..c7ef31375 --- /dev/null +++ b/src/io/resample/reorientLabelImage.ts @@ -0,0 +1,86 @@ +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkImageReslice from '@kitware/vtk.js/Imaging/Core/ImageReslice'; +import { InterpolationMode } from '@kitware/vtk.js/Imaging/Core/AbstractImageInterpolator/Constants'; +import { mat4, vec3 } from 'gl-matrix'; + +/** Reorder an equivalent voxel grid without interpolating label boundaries. */ +export function reorientLabelImage(target: vtkImageData, source: vtkImageData) { + // ImageReslice produces zero-based output extents. + if ( + [target, source].some((image) => + [0, 2, 4].some((axis) => image.getExtent()[axis] !== 0) + ) + ) + return null; + const matrix = mat4.multiply( + mat4.create(), + source.getWorldToIndex(), + target.getIndexToWorld() + ); + const tolerance = 1e-3; + const axes = [0, 1, 2].map((column) => { + const values = [0, 1, 2].map((row) => matrix[4 * column + row]); + const axis = values.findIndex((value) => Math.abs(value) > 0.5); + if ( + axis < 0 || + values.some((value, row) => + row === axis + ? Math.abs(Math.abs(value) - 1) > tolerance + : Math.abs(value) > tolerance + ) + ) + return -1; + return axis; + }); + if (axes.includes(-1) || new Set(axes).size !== 3) return null; + const sourceSize = source.getDimensions(); + if ( + target.getDimensions().some((size, axis) => size !== sourceSize[axes[axis]]) + ) + return null; + + // Check the entire extent so rounding error cannot accumulate into a shift + // at the far edge. Matching physical bounds alone does not imply equal grids. + const from = target.getExtent(); + const to = source.getExtent(); + for (let corner = 0; corner < 8; corner++) { + const point = vec3.fromValues( + from[corner & 1], + from[2 + ((corner >> 1) & 1)], + from[4 + ((corner >> 2) & 1)] + ); + vec3.transformMat4(point, point, matrix); + if ( + [0, 1, 2].some( + (axis) => + Math.min( + Math.abs(point[axis] - to[axis * 2]), + Math.abs(point[axis] - to[axis * 2 + 1]) + ) > tolerance + ) + ) + return null; + } + + // The corner check just proved both grids coincide within `tolerance`, so an + // unpermuted, unflipped mapping means the source already sits on the target + // grid. Demanding a bit-exact identity here instead would reslice every real + // image, whose transforms never multiply back to exactly one. + if (axes.every((axis, column) => axis === column && matrix[5 * column] > 0)) + return source; + + const filter = vtkImageReslice.newInstance(); + filter.setOutputOrigin(target.getOrigin()); + filter.setOutputSpacing(target.getSpacing()); + filter.setOutputDirection(target.getDirection()); + filter.setOutputExtent(target.getExtent()); + filter.setOutputDimensionality(3); + filter.setTransformInputSampling(false); + filter.setInterpolationMode(InterpolationMode.NEAREST); + try { + filter.setInputData(source); + return filter.getOutputData() as vtkImageData; + } finally { + filter.delete(); + } +} diff --git a/src/io/resample/resample.ts b/src/io/resample/resample.ts index 711bcc734..01671c7cb 100644 --- a/src/io/resample/resample.ts +++ b/src/io/resample/resample.ts @@ -3,7 +3,7 @@ import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; import { compareImageSpaces } from '@/src/utils/imageSpace'; import { runWasm } from './itkWasmUtils'; - +import { reorientLabelImage } from './reorientLabelImage'; export async function resample(fixed: Image, moving: Image, label = false) { const labelFlag = label ? ['--label'] : []; @@ -23,14 +23,21 @@ export async function resample(fixed: Image, moving: Image, label = false) { return runWasm('resample', args, [moving]); } -export async function ensureSameSpace(target: vtkImageData, resampleCandidate: vtkImageData, label = false) { - if (compareImageSpaces(target, resampleCandidate)) { +export async function ensureSameSpace( + target: vtkImageData, + resampleCandidate: vtkImageData, + label = false +) { + if (label) { + const reoriented = reorientLabelImage(target, resampleCandidate); + if (reoriented) return reoriented; + } else if (compareImageSpaces(target, resampleCandidate)) { return resampleCandidate; // could still be different pixel dimensions - } + } const itkImage = await resample( vtkITKHelper.convertVtkToItkImage(target), vtkITKHelper.convertVtkToItkImage(resampleCandidate), label ); return vtkITKHelper.convertItkToVtkImage(itkImage); -} \ No newline at end of file +} diff --git a/src/io/segNrrdMetadata.ts b/src/io/segNrrdMetadata.ts index 1222c73ac..88d6f0a40 100644 --- a/src/io/segNrrdMetadata.ts +++ b/src/io/segNrrdMetadata.ts @@ -1,18 +1,18 @@ import { clampValue } from '@/src/utils'; -import type { SegmentGroupMetadata } from '@/src/store/segmentGroups'; +import type { LabelmapSegment } from '@/src/segmentation/model'; const toColorString = (r: number, g: number, b: number) => [r / 255, g / 255, b / 255].map((c) => c.toFixed(6)).join(' '); /** - * Builds Slicer-compatible .seg.nrrd metadata entries from VolView segment group metadata. + * Builds Slicer-compatible .seg.nrrd metadata entries from a labelmap's segment descriptors. * Returns a Map suitable for setting on an itk-wasm Image's metadata field. * - * @param metadata - segment group metadata (names, colors, label values) + * @param segments - label descriptors (names, colors, label values) in write order * @param dimensions - [x, y, z] voxel dimensions of the labelmap */ export const buildSegNrrdMetadata = ( - metadata: SegmentGroupMetadata, + segments: LabelmapSegment[], dimensions: [number, number, number] ): Map => { const entries = new Map(); @@ -23,17 +23,14 @@ export const buildSegNrrdMetadata = ( const extentStr = `0 ${dimensions[0] - 1} 0 ${dimensions[1] - 1} 0 ${dimensions[2] - 1}`; - metadata.segments.order.forEach((segmentValue, index) => { - const segment = metadata.segments.byValue[segmentValue]; - if (!segment) return; - + segments.forEach((segment, index) => { const prefix = `Segment${index}`; const [r, g, b] = segment.color; - entries.set(`${prefix}_ID`, `Segment_${segmentValue}`); + entries.set(`${prefix}_ID`, `Segment_${segment.value}`); entries.set(`${prefix}_Name`, segment.name); entries.set(`${prefix}_Color`, toColorString(r, g, b)); - entries.set(`${prefix}_LabelValue`, String(segmentValue)); + entries.set(`${prefix}_LabelValue`, String(segment.value)); entries.set(`${prefix}_Layer`, '0'); entries.set(`${prefix}_Extent`, extentStr); entries.set(`${prefix}_Tags`, '|'); @@ -44,11 +41,11 @@ export const buildSegNrrdMetadata = ( export const maybeBuildSegNrrdMetadata = ( format: string, - segMetadata: SegmentGroupMetadata, + segments: LabelmapSegment[], dimensions: [number, number, number] ): Map | undefined => format === 'seg.nrrd' - ? buildSegNrrdMetadata(segMetadata, dimensions) + ? buildSegNrrdMetadata(segments, dimensions) : undefined; // --------------------------------------------------------------------------- @@ -72,7 +69,7 @@ export type ParsedSegment = { // Accept both Slicer's normalized RGB floats and the 0–255 integer convention // emitted by some other writers. Each channel is clamped so a hand-edited -// header cannot leak a negative or >255 value into `SegmentMask.color`. +// header cannot leak a negative or >255 value into a segment color. const fromColorString = (raw: string): [number, number, number] | undefined => { const parts = raw.trim().split(/\s+/).map(Number); if (parts.length < 3 || parts.slice(0, 3).some((n) => !Number.isFinite(n))) diff --git a/src/io/state-file/__tests__/annotationToolSource.spec.ts b/src/io/state-file/__tests__/annotationToolSource.spec.ts index 1f3c2e82b..286e01362 100644 --- a/src/io/state-file/__tests__/annotationToolSource.spec.ts +++ b/src/io/state-file/__tests__/annotationToolSource.spec.ts @@ -91,8 +91,6 @@ describe('annotation tool source', () => { expect(ManifestSchema.safeParse(bad).success).toBe(false); }); - // The annotation `source` field is additive-optional, so 6.4.0 remains the - // current manifest version and passes through untouched. it('passes a 6.4.0 manifest without touching its tools', () => { const old = JSON.stringify({ version: '6.4.0', diff --git a/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts b/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts new file mode 100644 index 000000000..2d4e4a967 --- /dev/null +++ b/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts @@ -0,0 +1,640 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { nextTick } from 'vue'; +import JSZip from 'jszip'; + +import { segmentRenderMask } from '@/src/segmentation/rendering/renderMask'; +import { leafStateId } from '@/src/io/import/dataSource'; +import { completeStateFileRestore } from '@/src/io/import/processors/restoreStateFile'; +import { migrateManifest } from '@/src/io/state-file/migrations'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { ManifestSchema, type Manifest } from '@/src/io/state-file/schema'; +import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import { listMasks } from '@/src/segmentation/model'; +import { isEmptyExtent } from '@/src/segmentation/geometry'; +import vtkLabelMap from '@/src/vtk/LabelMap'; +import { type LabelmapIO } from '@/src/segmentation/store'; +import { + addMask, + inMemoryArtifactIO, + manifestForImages, + serializeToStateFiles, + extentOf, + markedVoxels, + parentImage, + seatImage, + seedVoxel, + store, + voxelCount, + type Index3, + boundMasks, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +// --------------------------------------------------------------------------- +// The state file carries N bounded masks. What goes into the archive is each +// segment's own mask at its own size, and what the binding's `extent` says is +// where that mask sits in the parent image. Restoring puts every segment back +// on the same parent voxels, which is the only thing a state file has to +// promise: the mask's dimensions are storage, its extent is meaning. +// +// A materialized mask that covers nothing is a real state now, not a migration +// placeholder, so an empty extent restores as an empty mask. +// --------------------------------------------------------------------------- + +const DIMENSIONS: Index3 = [4, 4, 4]; +const GRID = { + dimensions: DIMENSIONS, + spacing: [2, 3, 4] as [number, number, number], + origin: [10, 20, 30] as [number, number, number], +}; + +/** The name the record shows, which lives on the type it references. */ +const nameOf = (segment: { segmentId: string }) => + useSegmentStore().segments.appearanceOf(segment.segmentId).name; + +/** Everything about a segment's voxels the round trip has to preserve. */ +const snapshot = (imageId: string) => + listMasks(store().getSegmentationForImage(imageId)!).map((segment) => ({ + name: nameOf(segment), + extent: segment.representations.labelmap + ? [...segment.representations.labelmap.extent] + : undefined, + dimensions: segment.representations.labelmap + ? store().maskVoxels(segment.id).image().getDimensions() + : undefined, + marks: markedVoxels(segment.id), + })); + +const wireSegmentation = (manifest: any) => + manifest.segmentations.find((entry: any) => entry.parentImage === 'img-1'); + +const wireSegmentId = (manifest: any, name: string) => + manifest.segments.find((type: any) => type.name === name)?.id; + +const wireMask = (manifest: any, name: string) => + wireSegmentation(manifest).masks.find( + (segment: any) => segment.segmentId === wireSegmentId(manifest, name) + ); + +/** The archive entry a wire mask names, and the name it saves under. */ +const wireStorage = (segment: any) => ({ + path: segment.representations.labelmap.path, + name: segment.representations.labelmap.name, +}); + +/** Points Node's binding at the very entry Tumor's binding names. */ +function pointNodeAtTumorEntry(manifest: any) { + const segmentation = wireSegmentation(manifest); + const tumor = wireMask(manifest, 'Tumor'); + const node = wireMask(manifest, 'Node'); + const tumorStorage = wireStorage(tumor); + const nodePath = node.representations.labelmap.path; + node.representations.labelmap.path = tumorStorage.path; + node.representations.labelmap.name = tumorStorage.name; + return { segmentation, tumor, node, tumorStorage, nodePath }; +} + +const restoredSegment = (name: string) => + listMasks(store().getSegmentationForImage('new-1')!).find( + (segment) => nameOf(segment) === name + )!; + +async function buildScene() { + await seatImage('img-1', { ...GRID, name: 'CT A' }); + await seatImage('img-2', { ...GRID, name: 'CT B' }); + + const tumor = addMask('img-1', 'Tumor'); + seedVoxel(tumor, [1, 1, 1]); + seedVoxel(tumor, [2, 1, 1]); + const node = addMask('img-1', 'Node'); + seedVoxel(node, [3, 3, 3]); + // Materialized and never drawn on: storage exists and covers nothing. + const planned = addMask('img-1', 'Planned'); + store().maskVoxels(planned).materialize(); + // No storage at all. + addMask('img-1', 'Unbound'); + + const other = addMask('img-2', 'Tumor'); + seedVoxel(other, [0, 0, 0]); + await nextTick(); +} + +const emptyManifest = () => manifestForImages(['img-1', 'img-2']); + +async function roundTrip(io: LabelmapIO, tamper?: (manifest: any) => void) { + const { parsed, stateFiles } = await serializeToStateFiles( + emptyManifest(), + io, + tamper + ); + + setActivePinia(createPinia()); + await seatImage('new-1', { ...GRID, name: 'CT A' }); + await seatImage('new-2', { ...GRID, name: 'CT B' }); + const result = await store().deserialize({ + manifest: parsed, + stateFiles: stateFiles, + dataIDMap: { 'img-1': 'new-1', 'img-2': 'new-2' }, + segmentIdMap: useSegmentStore().deserialize(parsed), + io: io, + }); + await nextTick(); + return result; +} + +describe('bounded masks through the state file', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('writes original bounded mask sizes after render padding', async () => { + await buildScene(); + const io = inMemoryArtifactIO(); + for (const imageId of ['img-1', 'img-2']) { + for (const mask of listMasks(store().getSegmentationForImage(imageId)!)) { + const binding = mask.representations.labelmap; + if (binding) + segmentRenderMask( + binding.image, + parentImage(imageId), + binding.extent, + { axis: 2, index: binding.extent[4] } + ); + } + } + + await store().serialize( + { zip: new JSZip(), manifest: emptyManifest() }, + io + ); + + const sizes = io.written.map((labelmap) => labelmap.getDimensions()); + expect(sizes).toContainEqual([2, 1, 1]); + expect(sizes).toContainEqual([1, 1, 1]); + expect(sizes).not.toContainEqual([...DIMENSIONS]); + expect( + io.written.every( + (labelmap) => + (labelmap.getPointData().getScalars().getData() as ArrayLike) + .length < voxelCount(DIMENSIONS) + ) + ).toBe(true); + }); + + it('restores every segment onto the parent voxels it had', async () => { + await buildScene(); + const before = { + first: snapshot('img-1'), + second: snapshot('img-2'), + }; + + await roundTrip(inMemoryArtifactIO()); + + expect(snapshot('new-1')).toEqual(before.first); + expect(snapshot('new-2')).toEqual(before.second); + // Spelled out once, so the equality above cannot pass on two full-extent + // masks that happen to match each other. + expect(snapshot('new-1')[0]).toMatchObject({ + name: 'Tumor', + extent: [1, 2, 1, 1, 1, 1], + dimensions: [2, 1, 1], + marks: [ + [1, 1, 1, 1], + [2, 1, 1, 1], + ], + }); + }); + + it('remaps label values when restored over a parent that already has segments', async () => { + await buildScene(); + // Reading a file yields fresh bytes each time; the in-memory codec has to + // copy to say the same, or both restores would share one mask. + const shared = inMemoryArtifactIO(); + const io = { + ...shared, + read: async (file: File) => { + const { image } = await shared.read(file); + const copy = vtkLabelMap.newInstance( + image.get('spacing', 'origin', 'direction') + ); + copy.setDimensions(image.getDimensions()); + copy.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array( + image.getPointData().getScalars().getData() as Uint8Array + ), + }) + ); + copy.computeTransforms(); + return { image: copy }; + }, + }; + const { parsed, stateFiles } = await serializeToStateFiles( + emptyManifest(), + io + ); + const dataIDMap = { 'img-1': 'img-1', 'img-2': 'img-2' }; + + setActivePinia(createPinia()); + await seatImage('img-1', { ...GRID, name: 'CT A' }); + await seatImage('img-2', { ...GRID, name: 'CT B' }); + // Each import adopts the incoming registry afresh, so the second pass + // brings its own segments rather than landing on the first pass's records. + const restore = () => + store().deserialize({ + manifest: parsed, + stateFiles: stateFiles, + dataIDMap: dataIDMap, + segmentIdMap: useSegmentStore().deserialize(parsed), + io: io, + }); + await restore(); + await restore(); + await nextTick(); + + const bound = listMasks(store().getSegmentationForImage('img-1')!) + .filter((segment) => segment.representations.labelmap) + .map((segment) => ({ + marks: markedVoxels(segment.id) ?? [], + })); + expect(bound).toHaveLength(6); + // Every mask holds its own segment and nothing else, so every mark it + // carries is SEGMENT_VALUE. + bound.forEach(({ marks }) => + marks.forEach((mark) => expect(mark[3]).toBe(SEGMENT_VALUE)) + ); + }); + + it('leaves an existing image display alone when a scene is imported onto it', async () => { + await buildScene(); + const io = inMemoryArtifactIO(); + const { parsed, stateFiles } = await serializeToStateFiles( + emptyManifest(), + io + ); + + // The scene the user is in already has masks and a display of its own. + setActivePinia(createPinia()); + await seatImage('img-1', { ...GRID, name: 'CT A' }); + await seatImage('img-2', { ...GRID, name: 'CT B' }); + const mine = addMask('img-1', 'Mine'); + seedVoxel(mine, [0, 0, 0]); + const segmentation = store().getSegmentationForImage('img-1')!; + store().updateSegmentationDisplay(segmentation.id, { + fillOpacity: 0.9, + outlineThickness: 7, + }); + + await store().deserialize({ + manifest: parsed, + stateFiles: stateFiles, + dataIDMap: { 'img-1': 'img-1', 'img-2': 'img-2' }, + segmentIdMap: useSegmentStore().deserialize(parsed), + io: io, + }); + await nextTick(); + + expect(segmentation.fillOpacity).toBe(0.9); + expect(segmentation.outlineThickness).toBe(7); + expect(segmentation.name).toBe('CT A'); + // The import still landed beside what was there. + expect(segmentation.order.length).toBeGreaterThan(1); + expect(markedVoxels(mine)).toEqual([[0, 0, 0, 1]]); + }); + + it('keeps bindings distinct when wire segmentation and segment ids repeat', async () => { + await buildScene(); + + await roundTrip(inMemoryArtifactIO(), (manifest) => { + const first = manifest.segmentations.find( + (entry: any) => entry.parentImage === 'img-1' + ); + const second = manifest.segmentations.find( + (entry: any) => entry.parentImage === 'img-2' + ); + const tumorTypeIds = manifest.segments + .filter((type: any) => type.name === 'Tumor') + .map((type: any) => type.id); + const findTumor = (segmentation: any) => + segmentation.masks.find((segment: any) => + tumorTypeIds.includes(segment.segmentId) + ); + const firstTumor = findTumor(first); + const secondTumor = findTumor(second); + + first.id = 'duplicate-segmentation'; + second.id = 'duplicate-segmentation'; + first.order = first.order.map((id: string) => + id === firstTumor.id ? 'duplicate-segment' : id + ); + second.order = second.order.map((id: string) => + id === secondTumor.id ? 'duplicate-segment' : id + ); + firstTumor.id = 'duplicate-segment'; + secondTumor.id = 'duplicate-segment'; + }); + + const firstTumor = listMasks( + store().getSegmentationForImage('new-1')! + ).find((segment) => nameOf(segment) === 'Tumor')!; + const secondTumor = listMasks( + store().getSegmentationForImage('new-2')! + ).find((segment) => nameOf(segment) === 'Tumor')!; + expect(firstTumor.representations.labelmap).toBeDefined(); + expect(secondTumor.representations.labelmap).toBeDefined(); + expect(markedVoxels(firstTumor.id)).toEqual([ + [1, 1, 1, 1], + [2, 1, 1, 1], + ]); + expect(markedVoxels(secondTumor.id)).toEqual([[0, 0, 0, 1]]); + expect(firstTumor.representations.labelmap!.image).not.toBe( + secondTumor.representations.labelmap!.image + ); + expect(boundMasks()).toHaveLength(4); + }); + + it('restores a mask that covers nothing as one that covers nothing', async () => { + await buildScene(); + + await roundTrip(inMemoryArtifactIO()); + + const planned = listMasks(store().getSegmentationForImage('new-1')!).find( + (segment) => nameOf(segment) === 'Planned' + )!; + expect(planned.representations.labelmap).toBeDefined(); + expect(isEmptyExtent(extentOf(planned.id)!)).toBe(true); + expect(store().maskVoxels(planned.id).scalars()).toHaveLength(0); + }); + + it('leaves a segment that never had storage without any', async () => { + await buildScene(); + + await roundTrip(inMemoryArtifactIO()); + + const unbound = listMasks(store().getSegmentationForImage('new-1')!).find( + (segment) => nameOf(segment) === 'Unbound' + )!; + expect(unbound.representations.labelmap).toBeUndefined(); + }); + + it('leaves a segment unbound when its entry belongs to another image', async () => { + await buildScene(); + + // A mask sits on its parent's grid, so an entry saved for another image + // gives this segment storage of a shape its own extent cannot describe. + await roundTrip(inMemoryArtifactIO(), (manifest) => { + const foreign = manifest.segmentations.find( + (entry: any) => entry.parentImage === 'img-2' + ); + const tumor = wireMask(manifest, 'Tumor'); + tumor.representations.labelmap.path = + foreign.masks[0].representations.labelmap.path; + }); + + const restored = listMasks(store().getSegmentationForImage('new-1')!); + const named = (name: string) => + restored.find((segment) => nameOf(segment) === name)!; + expect(named('Tumor').representations.labelmap).toBeUndefined(); + // The image's other segments restore as they were. + expect(named('Node').representations.labelmap).toBeDefined(); + expect(markedVoxels(named('Node').id)).toEqual([[3, 3, 3, SEGMENT_VALUE]]); + }); + + it('rejects an empty extent that points at foreground mask data', async () => { + await buildScene(); + + let storage: ReturnType; + const result = await roundTrip(inMemoryArtifactIO(), (manifest) => { + const tumor = wireMask(manifest, 'Tumor'); + storage = wireStorage(tumor); + tumor.representations.labelmap.extent = [0, -1, 0, -1, 0, -1]; + }); + + const tumor = restoredSegment('Tumor'); + expect(tumor.representations.labelmap).toBeUndefined(); + expect(result.skipped).toContainEqual({ + name: storage!.name, + reason: 'empty extent references a mask with foreground voxels', + }); + expect(boundMasks()).toHaveLength(3); + }); + + // Each mask reads its own archive entry into its own buffer, so two masks + // naming one entry get a copy each and neither aliases the other's voxels. + it('gives two masks naming one entry storage of their own', async () => { + await buildScene(); + + await roundTrip(inMemoryArtifactIO(), (manifest) => { + const refs = pointNodeAtTumorEntry(manifest); + refs.node.representations.labelmap.extent = [1, 2, 1, 1, 1, 1]; + }); + + const tumor = restoredSegment('Tumor'); + const node = restoredSegment('Node'); + expect(store().maskVoxels(tumor.id).image()).not.toBe( + store().maskVoxels(node.id).image() + ); + expect(markedVoxels(tumor.id)).toEqual([ + [1, 1, 1, 1], + [2, 1, 1, 1], + ]); + expect(markedVoxels(node.id)).toEqual([ + [1, 1, 1, 1], + [2, 1, 1, 1], + ]); + expect(boundMasks()).toHaveLength(4); + }); + + it('does not let an earlier empty binding erase a later valid binding', async () => { + await buildScene(); + + let refs: ReturnType; + const result = await roundTrip(inMemoryArtifactIO(), (manifest) => { + refs = pointNodeAtTumorEntry(manifest); + refs.node.representations.labelmap.extent = [0, -1, 0, -1, 0, -1]; + refs.segmentation.order = [ + refs.node.id, + ...refs.segmentation.order.filter((id: string) => id !== refs.node.id), + ]; + }); + + const tumor = restoredSegment('Tumor'); + expect(restoredSegment('Node').representations.labelmap).toBeUndefined(); + expect(store().maskVoxels(tumor.id).image().getDimensions()).toEqual([ + 2, 1, 1, + ]); + expect(markedVoxels(tumor.id)).toEqual([ + [1, 1, 1, 1], + [2, 1, 1, 1], + ]); + expect(result.skipped).toContainEqual({ + name: refs!.tumorStorage.name, + reason: 'empty extent references a mask with foreground voxels', + }); + }); + + it.each([ + { + title: 'an extent whose size differs from its loaded mask', + extent: [1, 3, 1, 1, 1, 1], + reason: 'extent does not match the loaded mask dimensions', + }, + { + title: 'an extent that leaves its parent image', + extent: [3, 4, 1, 1, 1, 1], + reason: 'extent leaves the parent image', + }, + ])('rejects $title', async ({ extent, reason }) => { + await buildScene(); + + let storage: ReturnType; + const result = await roundTrip(inMemoryArtifactIO(), (manifest) => { + const tumor = wireMask(manifest, 'Tumor'); + storage = wireStorage(tumor); + tumor.representations.labelmap.extent = extent; + }); + + expect(restoredSegment('Tumor').representations.labelmap).toBeUndefined(); + expect(result.skipped).toContainEqual({ name: storage!.name, reason }); + expect(boundMasks()).toHaveLength(3); + }); + + it('keeps a valid binding when another naming the same entry is invalid', async () => { + await buildScene(); + + // Node keeps its own extent, which does not describe Tumor's entry. + let refs: ReturnType; + const result = await roundTrip(inMemoryArtifactIO(), (manifest) => { + refs = pointNodeAtTumorEntry(manifest); + }); + + expect(restoredSegment('Tumor').representations.labelmap).toBeDefined(); + expect(restoredSegment('Node').representations.labelmap).toBeUndefined(); + expect(result.skipped).toContainEqual({ + name: refs!.tumorStorage.name, + reason: 'extent does not match the loaded mask dimensions', + }); + }); + + it('puts the restored masks back on the parent grid', async () => { + await buildScene(); + + await roundTrip(inMemoryArtifactIO()); + + const tumor = listMasks(store().getSegmentationForImage('new-1')!).find( + (segment) => nameOf(segment) === 'Tumor' + )!; + const mask = store().maskVoxels(tumor.id).image(); + expect(Array.from(mask.indexToWorld([0, 0, 0] as never))).toEqual( + Array.from(parentImage('new-1').indexToWorld([1, 1, 1] as never)) + ); + expect(Array.from(mask.getSpacing())).toEqual( + Array.from(parentImage('new-1').getSpacing()) + ); + }); +}); + +// A pre-7.0.0 group: one labelmap file, no segment descriptors. Restore decodes +// its voxel values into segments, and each of those gets its own bounded mask +// like any other import. +/** A parent image and a two-value labelmap, seated as separate imports. */ +const seatLegacyPair = async () => { + await seatImage('parent-store', { ...GRID, name: 'CT Chest' }); + const values = new Uint8Array(voxelCount(DIMENSIONS)); + values[1 + 1 * 4 + 1 * 16] = 1; + values[3 + 3 * 4 + 3 * 16] = 2; + return seatImage('artifact-store', { + ...GRID, + name: 'Tumor.seg.nrrd', + values, + }); +}; + +/** The sources both legacy restores read, migrated to the current schema. */ +const legacyPairManifest = (scene: Record) => + ManifestSchema.parse( + migrateManifest( + JSON.stringify({ + dataSources: [ + { id: 1, type: 'uri', uri: 'volview-backend:base/ct', name: 'CT' }, + { + id: 3, + type: 'uri', + uri: 'volview-backend:artifact/tumor', + name: 'Tumor.seg.nrrd', + mime: 'application/octet-stream', + }, + ], + datasets: [{ id: 'ds-ct', dataSourceId: 1 }], + ...scene, + }) + ) + ); + +/** Restores onto the seated pair and lists what the parent ended up with. */ +const restoreLegacyPair = async (manifest: Manifest) => { + await completeStateFileRestore(manifest, [], { + 'ds-ct': 'parent-store', + [leafStateId(3)]: 'artifact-store', + }); + return listMasks(store().getSegmentationForImage('parent-store')!); +}; + +/** Both restores split the labelmap into the same two named segments. */ +const expectTumorSegments = ( + segments: Array<{ id: string; segmentId: string }> +) => { + expect(segments.map(nameOf)).toEqual(['Tumor 1', 'Tumor 2']); + expect(markedVoxels(segments[0].id)).toEqual([[1, 1, 1, SEGMENT_VALUE]]); + expect(markedVoxels(segments[1].id)).toEqual([[3, 3, 3, SEGMENT_VALUE]]); +}; + +describe('a legacy group restored as bounded masks', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('enumerates a current-version artifact no segment binds', async () => { + await seatLegacyPair(); + const segments = await restoreLegacyPair( + legacyPairManifest({ + version: MANIFEST_VERSION, + segmentationArtifacts: [ + { + id: 'sa-tumor', + parentImage: 'ds-ct', + name: 'Tumor', + dataSourceId: 3, + }, + ], + }) + ); + expectTumorSegments(segments); + }); + + it('bounds each decoded segment to the voxels its value covers', async () => { + const labelmap = await seatLegacyPair(); + expect(labelmap.getPointData().getScalars().getData()).toHaveLength( + voxelCount(DIMENSIONS) + ); + + const segments = await restoreLegacyPair( + legacyPairManifest({ + version: '6.4.0', + segmentGroups: [ + { + id: 'sg-tumor', + dataSourceId: 3, + metadata: { name: 'Tumor', parentImage: 'ds-ct' }, + }, + ], + }) + ); + expectTumorSegments(segments); + expect(extentOf(segments[0].id)).toEqual([1, 1, 1, 1, 1, 1]); + expect(extentOf(segments[1].id)).toEqual([3, 3, 3, 3, 3, 3]); + }); +}); diff --git a/src/io/state-file/__tests__/droppedMaskReport.spec.ts b/src/io/state-file/__tests__/droppedMaskReport.spec.ts new file mode 100644 index 000000000..bc5fc3efb --- /dev/null +++ b/src/io/state-file/__tests__/droppedMaskReport.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; + +import { + seatSpecImage as seatImage, + inMemoryArtifactIO, + mintSegment, + manifestForImages, + serializeToStateFiles, + store, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; + +// --------------------------------------------------------------------------- +// A restore drops a mask it cannot give an identity to. The module's policy is +// that no drop is silent, so each of the three reasons has to reach the report +// the caller surfaces, naming the mask it lost. +// --------------------------------------------------------------------------- + +const buildScene = async () => { + await seatImage('img-1', 'CT A'); + const segmentation = store().ensureSegmentationForImage('img-1'); + ['Liver', 'Tumor'].forEach((name) => { + const mask = store().createMask(segmentation.id, mintSegment({ name })); + store().ensureLabelmapBinding(mask.id); + }); +}; + +/** Serializes a two-mask scene, tampers with the manifest, then restores it. */ +const restoreTampered = async ( + tamper: (parsed: any) => void, + segmentIdMapFor: (parsed: any) => Record = (parsed) => + useSegmentStore().deserialize(parsed) +) => { + await buildScene(); + const io = inMemoryArtifactIO(); + const { parsed, stateFiles } = await serializeToStateFiles( + manifestForImages(['img-1']), + io, + tamper + ); + + setActivePinia(createPinia()); + await seatImage('new-1', 'CT A'); + const result = await useSegmentationStore().deserialize({ + manifest: parsed, + stateFiles, + dataIDMap: { 'img-1': 'new-1' }, + segmentIdMap: segmentIdMapFor(parsed), + io, + }); + return { + skipped: result.skipped, + masks: store().getSegmentationForImage('new-1')?.order ?? [], + }; +}; + +describe('masks dropped during restore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('reports a mask whose segment the file never names', async () => { + const { skipped, masks } = await restoreTampered( + (parsed) => { + delete parsed.segments; + }, + () => ({}) + ); + + expect(masks).toHaveLength(0); + expect(skipped.map(({ reason }) => reason)).toEqual([ + 'its segment is not in the file', + 'its segment is not in the file', + ]); + expect(skipped.every(({ name }) => name.length > 0)).toBe(true); + }); + + it('reports a mask whose segment did not restore', async () => { + const { skipped, masks } = await restoreTampered( + () => {}, + (parsed) => + Object.fromEntries( + parsed.segments.map((segment: any) => [segment.id, 'no-such-segment']) + ) + ); + + expect(masks).toHaveLength(0); + expect(skipped.map(({ reason }) => reason)).toEqual([ + 'its segment did not restore', + 'its segment did not restore', + ]); + }); + + it('reports a second mask for a segment the image already has', async () => { + const { skipped, masks } = await restoreTampered((parsed) => { + const [first, second] = parsed.segmentations[0].masks; + second.segmentId = first.segmentId; + }); + + expect(masks).toHaveLength(1); + expect(skipped).toEqual([ + { + name: expect.any(String), + reason: 'the image already has a mask for its segment', + }, + ]); + }); +}); diff --git a/src/io/state-file/__tests__/legacyManifestMigration.spec.ts b/src/io/state-file/__tests__/legacyManifestMigration.spec.ts new file mode 100644 index 000000000..c113414f5 --- /dev/null +++ b/src/io/state-file/__tests__/legacyManifestMigration.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { ManifestSchema } from '@/src/io/state-file/schema'; +import { migrateManifest } from '@/src/io/state-file/migrations'; +import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; + +describe('legacy manifest migration', () => { + it('migrates a 6.3.0 manifest to the current version, converting segment groups', () => { + const old = JSON.stringify({ + version: '6.3.0', + dataSources: [], + segmentGroups: [ + { + id: 'sg-1', + dataSourceId: 7, + metadata: { + name: 'Painted', + parentImage: 'img-1', + segments: { order: [], byValue: {} }, + }, + }, + ], + }); + const migrated = migrateManifest(old) as any; + + expect(migrated.version).toBe(MANIFEST_VERSION); + expect(migrated.segmentGroups).toBeUndefined(); + expect(migrated.segmentationArtifacts).toHaveLength(1); + expect(migrated.segmentationArtifacts[0]).toMatchObject({ + id: 'sg-1', + dataSourceId: 7, + parentImage: 'img-1', + name: 'Painted', + }); + // An empty descriptor block is a KNOWN empty catalog, not a pending decode. + expect(migrated.segmentationArtifacts[0].pendingDecode).toBeFalsy(); + // An old manifest lacking `source` still validates (additive-optional). + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); +}); diff --git a/src/io/state-file/__tests__/maskArchivePath.spec.ts b/src/io/state-file/__tests__/maskArchivePath.spec.ts new file mode 100644 index 000000000..e7d7007b1 --- /dev/null +++ b/src/io/state-file/__tests__/maskArchivePath.spec.ts @@ -0,0 +1,25 @@ +import { makeMaskArchivePath } from '@/src/io/state-file/maskArchivePath'; +import { describe, expect, it } from 'vitest'; + +describe('io/state-file/maskArchivePath', () => { + describe('makeMaskArchivePath', () => { + it('uses a sanitized mask filename stem in the archive path', () => { + const usedPaths = new Set(); + + expect( + makeMaskArchivePath('Liver: left/right*?', 'vti', usedPaths) + ).to.equal('segmentations/Liver left right.vti'); + }); + + it('deduplicates colliding sanitized names case-insensitively', () => { + const usedPaths = new Set(); + + expect(makeMaskArchivePath('Liver/Left', 'vti', usedPaths)).to.equal( + 'segmentations/Liver Left.vti' + ); + expect(makeMaskArchivePath('liver:left', 'vti', usedPaths)).to.equal( + 'segmentations/liver left (2).vti' + ); + }); + }); +}); diff --git a/src/io/state-file/__tests__/maskIoConcurrency.spec.ts b/src/io/state-file/__tests__/maskIoConcurrency.spec.ts new file mode 100644 index 000000000..f37f8dc67 --- /dev/null +++ b/src/io/state-file/__tests__/maskIoConcurrency.spec.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { nextTick } from 'vue'; + +import { + seatSpecImage as seatImage, + inMemoryArtifactIO, + mintSegment, + manifestForImages, + serializeToStateFiles, + store, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; + +// --------------------------------------------------------------------------- +// Every mask is a codec call of its own, and every codec call is a worker of +// its own, so save and restore bound how many they run at a time. These count +// the calls in flight around an in-memory IO that settles a few microtasks +// late, which is enough for an unbounded fan-out to overlap completely. +// --------------------------------------------------------------------------- + +const MASK_COUNT = 12; +const LIMIT = 4; + +/** Resolves after enough microtasks for every already-started call to start. */ +const settleLate = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +/** The in-memory IO, wrapped so each half records its peak calls in flight. */ +const countingIO = () => { + const inner = inMemoryArtifactIO(); + const peak = { write: 0, read: 0 }; + const inFlight = { write: 0, read: 0 }; + const around = ( + half: 'write' | 'read', + call: (...args: A) => Promise + ) => { + return async (...args: A) => { + inFlight[half] += 1; + peak[half] = Math.max(peak[half], inFlight[half]); + try { + await settleLate(); + return await call(...args); + } finally { + inFlight[half] -= 1; + } + }; + }; + return { + peak, + write: around('write', inner.write), + read: around('read', inner.read), + }; +}; + +const buildScene = async () => { + await seatImage('img-1', 'CT A'); + const segmentation = store().ensureSegmentationForImage('img-1'); + for (let index = 0; index < MASK_COUNT; index += 1) { + const mask = store().createMask( + segmentation.id, + mintSegment({ name: `Segment ${index}` }) + ); + store().ensureLabelmapBinding(mask.id); + } + await nextTick(); +}; + +describe('mask io concurrency', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('writes and reads every mask, a bounded number at a time', async () => { + await buildScene(); + + const io = countingIO(); + const { parsed, stateFiles } = await serializeToStateFiles( + manifestForImages(['img-1']), + io + ); + const wire = parsed.segmentations[0]; + expect(wire.masks).toHaveLength(MASK_COUNT); + expect(io.peak.write).toBe(LIMIT); + + setActivePinia(createPinia()); + await seatImage('new-1', 'CT A'); + const segmentIdMap = useSegmentStore().deserialize(parsed); + const result = await useSegmentationStore().deserialize({ + manifest: parsed, + stateFiles, + dataIDMap: { 'img-1': 'new-1' }, + segmentIdMap, + io, + }); + await nextTick(); + + expect(io.peak.read).toBe(LIMIT); + expect(result.skipped).toEqual([]); + expect(store().getSegmentationForImage('new-1')!.order).toHaveLength( + MASK_COUNT + ); + }); + + it('keeps the masks in wire order', async () => { + await buildScene(); + + const io = countingIO(); + const { parsed, stateFiles } = await serializeToStateFiles( + manifestForImages(['img-1']), + io + ); + const names = parsed.segments.map((segment: any) => segment.name); + + setActivePinia(createPinia()); + await seatImage('new-1', 'CT A'); + const segmentIdMap = useSegmentStore().deserialize(parsed); + await useSegmentationStore().deserialize({ + manifest: parsed, + stateFiles, + dataIDMap: { 'img-1': 'new-1' }, + segmentIdMap, + io, + }); + await nextTick(); + + const segments = useSegmentStore().segments; + const segmentation = store().getSegmentationForImage('new-1')!; + expect( + segmentation.order.map( + (maskId) => + segments.appearanceOf(segmentation.masks[maskId].segmentId).name + ) + ).toEqual(names); + }); +}); diff --git a/src/io/state-file/__tests__/migrate640To700.spec.ts b/src/io/state-file/__tests__/migrate640To700.spec.ts new file mode 100644 index 000000000..52bf7fba9 --- /dev/null +++ b/src/io/state-file/__tests__/migrate640To700.spec.ts @@ -0,0 +1,1087 @@ +import { beforeEach, describe, expect, it } 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 { ManifestSchema } from '@/src/io/state-file/schema'; +import { migrateManifest } from '@/src/io/state-file/migrations'; +import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; +import { leafStateId } from '@/src/io/import/dataSource'; +import { completeStateFileRestore } from '@/src/io/import/processors/restoreStateFile'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { DEFAULT_SEGMENTATION_FILL_OPACITY } from '@/src/segmentation/model'; +import { segmentFillAlpha } from '@/src/segmentation/rendering/display'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { + legacyAxialViewConfig, + inMemoryArtifactIO, + manifestForImages, + segmentationSnapshot, + serializeToStateFiles, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +// --------------------------------------------------------------------------- +// The 6.4.0 -> 7.0.0 structural migration. JSON only: every old segment group +// becomes one `SegmentationArtifact`, every `{group, value}` becomes one +// segment type plus one per-image mask, and every old tool label becomes one +// type in the one registry that now backs paint and the vector tools. A tool +// label lands on the type of the same name; groups never merge with each +// other, whatever they are called. +// --------------------------------------------------------------------------- + +const SOURCE = { + providerId: 'analysis-provider', + jobId: 'job-abc', + outputId: 'outputLabelmap', +}; + +const legacyManifest = (overrides: Record) => + JSON.stringify({ + version: '6.4.0', + dataSources: [ + { id: 1, type: 'uri', uri: 'https://ex/ct.nrrd', name: 'CT' }, + { id: 2, type: 'uri', uri: 'https://ex/mr.nrrd', name: 'MR' }, + ], + datasets: [ + { id: 'ds-ct', dataSourceId: 1 }, + { id: 'ds-mr', dataSourceId: 2 }, + ], + ...overrides, + }); + +const migrate = (overrides: Record) => + migrateManifest(legacyManifest(overrides)) as any; + +type LegacyMask = { + value: number; + name: string; + color: [number, number, number, number]; + visible?: boolean; + locked?: boolean; +}; + +const segmentsBlock = (masks: LegacyMask[]) => ({ + order: masks.map((mask) => mask.value), + byValue: Object.fromEntries(masks.map((mask) => [String(mask.value), mask])), +}); + +const legacyGroup = ( + id: string, + parentImage: string, + masks?: LegacyMask[], + extras: Record = {} +) => ({ + id, + path: `segmentations/${id}.vti`, + metadata: { + name: id, + parentImage, + ...(masks ? { segments: segmentsBlock(masks) } : {}), + ...extras, + }, +}); + +const TUMOR: LegacyMask = { + value: 1, + name: 'Tumor', + color: [255, 0, 0, 255], + visible: true, + locked: true, +}; +const EDEMA: LegacyMask = { + value: 2, + name: 'Edema', + color: [0, 255, 0, 128], + visible: false, +}; +// No `visible`/`locked` keys at all: the old schema defaulted them on parse. +const BARE = { value: 3, name: '', color: [1, 2, 3, 4] } as LegacyMask; + +// What the slice renderer multiplies out for a visible, opaque-coloured +// segment: the type's fill opacity times the image's multiplier. A legacy +// group's opacity has to survive as this product, not as either factor alone. +const effectiveFill = (migrated: any, record: any, segmentation: any) => + segmentFillAlpha( + { + visible: true, + color: [0, 0, 0, 255], + fillOpacity: segmentOfMask(migrated, record)?.fillOpacity ?? 1, + } as any, + segmentation.fillOpacity + ); + +const segmentationFor = (migrated: any, parentImage: string) => + migrated.segmentations.find( + (entry: any) => entry.parentImage === parentImage + ); + +/** The segment a migrated mask references, off the manifest's own registry. */ +const segmentOfMask = (migrated: any, mask: any) => + migrated.segments.find((segment: any) => segment.id === mask.segmentId); + +/** Wire masks of one segmentation with their segment, in `order`. */ +const namedMasks = (migrated: any, segmentation: any) => + orderedMasks(segmentation).map((mask: any) => ({ + ...mask, + segment: segmentOfMask(migrated, mask), + })); + +/** Wire masks of one segmentation, in `order`. */ +const orderedMasks = (segmentation: any) => + segmentation.order.map((id: string) => + segmentation.masks.find((segment: any) => segment.id === id) + ); + +const boundTo = (segmentation: any, artifactId: string, sourceValue: number) => + orderedMasks(segmentation).find( + (segment: any) => + segment.representations.labelmap?.artifactId === artifactId && + segment.representations.labelmap?.sourceValue === sourceValue + ); + +describe('migrate640To700: structural stage', () => { + it('reaches the current manifest version from every legacy version', () => { + ['6.2.0', '6.3.0', '6.4.0'].forEach((version) => { + const migrated = migrateManifest( + JSON.stringify({ version, dataSources: [] }) + ) as any; + expect(migrated.version).toBe(MANIFEST_VERSION); + }); + }); + + it('migrates a one-group manifest losslessly', () => { + const migrated = migrate({ + segmentGroups: [ + legacyGroup('sg-1', 'ds-ct', [TUMOR, EDEMA, BARE], { + name: 'Painted', + source: SOURCE, + }), + ], + }); + + expect(migrated.version).toBe(MANIFEST_VERSION); + expect(migrated.segmentGroups).toBeUndefined(); + + expect(migrated.segmentationArtifacts).toHaveLength(1); + expect(migrated.segmentationArtifacts[0]).toMatchObject({ + id: 'sg-1', + parentImage: 'ds-ct', + name: 'Painted', + path: 'segmentations/sg-1.vti', + source: SOURCE, + }); + expect(migrated.segmentationArtifacts[0].pendingDecode).toBeFalsy(); + + expect(migrated.segmentations).toHaveLength(1); + const segmentation = migrated.segmentations[0]; + expect(segmentation.parentImage).toBe('ds-ct'); + expect(segmentation.order).toHaveLength(3); + + const segments = namedMasks(migrated, segmentation); + expect( + segments.map((segment: any) => ({ + name: segment.segment.name, + color: segment.segment.color, + visible: segment.segment.visible, + locked: segment.segment.locked, + sourceValue: segment.representations.labelmap.sourceValue, + artifactId: segment.representations.labelmap.artifactId, + })) + ).toEqual([ + { + name: 'Tumor', + color: [255, 0, 0, 255], + visible: true, + locked: true, + sourceValue: 1, + artifactId: 'sg-1', + }, + { + name: 'Edema', + color: [0, 255, 0, 128], + visible: false, + locked: false, + sourceValue: 2, + artifactId: 'sg-1', + }, + { + name: '', + color: [1, 2, 3, 4], + visible: true, + locked: false, + sourceValue: 3, + artifactId: 'sg-1', + }, + ]); + // The extent is a placeholder resolved against the parent at load time. + segments.forEach((segment: any) => + expect(segment.representations.labelmap.extent).toHaveLength(6) + ); + + const parsed = ManifestSchema.parse(migrated); + // Parse fills the 7.0.0 display-state defaults the raw migration output + // does not carry; add those to the raw output before checking the + // migration itself is otherwise lossless. + const expectedSegmentations = migrated.segmentations.map((wire: any) => ({ + ...wire, + fillOpacity: DEFAULT_SEGMENTATION_FILL_OPACITY, + outlineOpacity: 1, + outlineThickness: 2, + })); + expect(parsed.segmentations).toEqual(expectedSegmentations); + expect(parsed.segments).toEqual(migrated.segments); + expect(parsed.segmentationArtifacts![0]).toMatchObject({ id: 'sg-1' }); + }); + + it.each([ + { + source: 'a named URI', + dataSources: [ + { id: 10, type: 'uri', uri: 'https://ex/scan.nrrd', name: 'CT Chest' }, + ], + expected: 'CT Chest', + }, + { + source: 'an unnamed URI', + dataSources: [{ id: 10, type: 'uri', uri: 'https://ex/scan.nrrd' }], + expected: 'scan.nrrd', + }, + { + source: 'a local file', + dataSources: [ + { id: 10, type: 'file', fileId: 42, fileType: 'application/nrrd' }, + ], + datasetFilePath: { '42': 'datasets/42/patient.nrrd' }, + expected: 'patient.nrrd', + }, + { + source: 'an archive member', + dataSources: [ + { id: 10, type: 'uri', uri: 'https://ex/study.zip' }, + { id: 11, type: 'archive', path: 'study/series/scan.nrrd', parent: 10 }, + ], + datasetSourceId: 11, + expected: 'scan.nrrd', + }, + { + source: 'a collection', + dataSources: [ + { id: 10, type: 'collection', sources: [11, 12] }, + { id: 11, type: 'uri', uri: 'https://ex/first.nrrd' }, + { id: 12, type: 'uri', uri: 'https://ex/second.nrrd' }, + ], + expected: 'first.nrrd, second.nrrd', + }, + ])('names a segmentation from $source', (testCase) => { + const migrated = migrate({ + dataSources: testCase.dataSources, + datasets: [ + { id: 'ds-local', dataSourceId: testCase.datasetSourceId ?? 10 }, + ], + ...(testCase.datasetFilePath + ? { datasetFilePath: testCase.datasetFilePath } + : {}), + segmentGroups: [legacyGroup('sg-1', 'ds-local', [TUMOR])], + }); + + expect(segmentationFor(migrated, 'ds-local').name).toBe(testCase.expected); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + it('preserves a path-less group’s dataSourceId', () => { + const migrated = migrate({ + segmentGroups: [ + { + id: 'sg-1', + dataSourceId: 7, + metadata: { name: 'Painted', parentImage: 'ds-ct' }, + }, + ], + }); + + expect(migrated.segmentationArtifacts[0]).toMatchObject({ + id: 'sg-1', + dataSourceId: 7, + }); + expect(migrated.segmentationArtifacts[0].path).toBeUndefined(); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + it('keeps multi-group order deterministic', () => { + const migrated = migrate({ + segmentGroups: [ + legacyGroup('sg-a', 'ds-ct', [TUMOR]), + legacyGroup('sg-b', 'ds-ct', [EDEMA]), + legacyGroup('sg-c', 'ds-mr', [TUMOR]), + ], + }); + + expect( + migrated.segmentationArtifacts.map((artifact: any) => artifact.id) + ).toEqual(['sg-a', 'sg-b', 'sg-c']); + expect( + migrated.segmentations.map((entry: any) => entry.parentImage) + ).toEqual(['ds-ct', 'ds-mr']); + + const ct = segmentationFor(migrated, 'ds-ct'); + expect( + namedMasks(migrated, ct).map((segment: any) => [ + segment.segment.name, + segment.representations.labelmap.artifactId, + ]) + ).toEqual([ + ['Tumor', 'sg-a'], + ['Edema', 'sg-b'], + ]); + }); + + it('keeps equal and default names distinct segments', () => { + const sameName: LegacyMask = { + value: 1, + name: 'Segment 1', + color: [10, 20, 30, 255], + visible: true, + }; + const migrated = migrate({ + segmentGroups: [ + legacyGroup('sg-a', 'ds-ct', [sameName]), + legacyGroup('sg-b', 'ds-ct', [sameName]), + ], + }); + + const ct = segmentationFor(migrated, 'ds-ct'); + const segments = namedMasks(migrated, ct); + expect(segments.map((segment: any) => segment.segment.name)).toEqual([ + 'Segment 1', + 'Segment 1', + ]); + expect(new Set(segments.map((segment: any) => segment.id)).size).toBe(2); + // One type per legacy segment: an equal name is not the same identity. + expect( + new Set(segments.map((segment: any) => segment.segmentId)).size + ).toBe(2); + expect( + segments.map( + (segment: any) => segment.representations.labelmap.artifactId + ) + ).toEqual(['sg-a', 'sg-b']); + }); + + it('lands a tool label on the type a group of that name already is', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-a', 'ds-ct', [TUMOR])], + tools: { + rectangles: { + tools: [ + { + imageID: 'ds-ct', + slice: 2, + frameOfReference: { + planeOrigin: [0, 0, 2], + planeNormal: [0, 0, 1], + }, + firstPoint: [1, 1, 2], + secondPoint: [4, 4, 2], + label: 'lbl-tumor', + }, + ], + labels: { + 'lbl-tumor': { labelName: 'Tumor', color: 'blue', strokeWidth: 3 }, + }, + }, + }, + }); + + const painted = segmentOfMask( + migrated, + orderedMasks(segmentationFor(migrated, 'ds-ct'))[0] + ); + expect(migrated.segments.map((segment: any) => segment.name)).toEqual([ + 'Tumor', + ]); + expect(migrated.tools.rectangles.tools[0].segmentId).toBe(painted.id); + // The group spoke for the name first, so the label brings no appearance. + expect(painted.color).toEqual(TUMOR.color); + expect(painted.strokeWidth).toBeUndefined(); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + // Two masks of one type on one image is a state the app cannot hold, and two + // images that painted "Tumor" separately each described their own thing. + it('keeps a name two groups carry on separate types', () => { + const migrated = migrate({ + segmentGroups: [ + legacyGroup('sg-a', 'ds-ct', [TUMOR]), + legacyGroup('sg-b', 'ds-mr', [TUMOR]), + ], + }); + + expect(migrated.segments.map((segment: any) => segment.name)).toEqual([ + 'Tumor', + 'Tumor', + ]); + expect( + new Set(migrated.segments.map((segment: any) => segment.id)).size + ).toBe(2); + }); + + it('marks a descriptorless group for decode and emits no segments for it', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-blind', 'ds-ct')], + }); + + expect(migrated.segmentationArtifacts).toHaveLength(1); + expect(migrated.segmentationArtifacts[0]).toMatchObject({ + id: 'sg-blind', + parentImage: 'ds-ct', + pendingDecode: true, + }); + expect( + (migrated.segmentations ?? []).flatMap((entry: any) => entry.masks) + ).toEqual([]); + + // The marker must survive the schema, or the loaded stage never sees it. + const parsed = ManifestSchema.parse(migrated) as any; + expect(parsed.segmentationArtifacts[0].pendingDecode).toBe(true); + }); + + it('carries the active value of a descriptorless group for post-decode restore', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-blind', 'ds-ct')], + tools: { paint: { activeSegmentGroupID: 'sg-blind', activeSegment: 2 } }, + }); + + // No segment exists to activate yet, so the value travels on the artifact. + expect( + (migrated.segmentations ?? []).flatMap((entry: any) => entry.masks) + ).toEqual([]); + expect(migrated.segmentationArtifacts[0].pendingActiveValue).toBe(2); + + const parsed = ManifestSchema.parse(migrated) as any; + expect(parsed.segmentationArtifacts[0].pendingActiveValue).toBe(2); + }); + + it('does not emit a pending active value for a legacy null selection', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-blind', 'ds-ct')], + tools: { + paint: { activeSegmentGroupID: 'sg-blind', activeSegment: null }, + }, + }); + + expect( + migrated.segmentationArtifacts[0].pendingActiveValue + ).toBeUndefined(); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + it('moves legacy group display settings onto the segment model', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-1', 'ds-ct', [TUMOR])], + viewByID: { + Axial: { + config: { + 'sg-1': { + layers: { blendConfig: { opacity: 0.4, visibility: false } }, + segmentGroup: { outlineOpacity: 0.25, outlineThickness: 5 }, + }, + }, + }, + }, + }); + + const segmentation = segmentationFor(migrated, 'ds-ct'); + const [record] = orderedMasks(segmentation); + // A legacy group described what it showed, so both land on its type. + expect(segmentOfMask(migrated, record)).toMatchObject({ + visible: false, + outlineOpacity: 0.25, + }); + expect(effectiveFill(migrated, record, segmentation)).toBeCloseTo(0.4); + expect(segmentation.outlineThickness).toBe(5); + // The types carry it, so the artifact carries no second copy for the + // split to reapply. + const artifact = migrated.segmentationArtifacts[0]; + expect(artifact).not.toHaveProperty('pendingFillOpacity'); + expect(artifact).not.toHaveProperty('pendingOutlineOpacity'); + expect(artifact).not.toHaveProperty('pendingVisibility'); + expect(migrated.viewByID.Axial.config['sg-1']).toBeUndefined(); + }); + + // A group with no descriptors names no segment, so nothing holds its display + // until the restore decodes its voxels; it rides on the artifact until then. + it('carries a descriptor-less group display on the artifact', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-1', 'ds-ct')], + viewByID: { + Axial: { + config: { + 'sg-1': { + layers: { blendConfig: { opacity: 0.4, visibility: false } }, + segmentGroup: { outlineOpacity: 0.25, outlineThickness: 5 }, + }, + }, + }, + }, + }); + + expect(migrated.segmentationArtifacts[0]).toMatchObject({ + pendingDecode: true, + pendingFillOpacity: 1, + pendingOutlineOpacity: 0.25, + pendingVisibility: false, + }); + }); + + it('keeps the legacy fill default when no view configured the group', () => { + const migrated = ManifestSchema.parse( + migrate({ segmentGroups: [legacyGroup('sg-1', 'ds-ct', [TUMOR])] }) + ) as any; + + const segmentation = segmentationFor(migrated, 'ds-ct'); + expect( + effectiveFill(migrated, orderedMasks(segmentation)[0], segmentation) + ).toBeCloseTo(DEFAULT_SEGMENTATION_FILL_OPACITY); + }); + + it('keeps each merged group’s own fill when they disagree', () => { + const migrated = ManifestSchema.parse( + migrate({ + segmentGroups: [ + legacyGroup('sg-1', 'ds-ct', [TUMOR]), + legacyGroup('sg-2', 'ds-ct', [EDEMA]), + ], + viewByID: { + Axial: { + id: 'Axial', + name: 'Axial', + type: '2D', + config: { + 'sg-1': { layers: { blendConfig: { opacity: 0.2 } } }, + 'sg-2': { layers: { blendConfig: { opacity: 0.8 } } }, + }, + }, + }, + }) + ) as any; + + const segmentation = segmentationFor(migrated, 'ds-ct'); + const [tumor, edema] = orderedMasks(segmentation); + expect(effectiveFill(migrated, tumor, segmentation)).toBeCloseTo(0.2); + expect(effectiveFill(migrated, edema, segmentation)).toBeCloseTo(0.8); + // The per-type share only holds a fraction, so the larger of the two is + // what the segmentation carries. + expect( + orderedMasks(segmentation).map( + (record: any) => segmentOfMask(migrated, record).fillOpacity <= 1 + ) + ).toEqual([true, true]); + }); + + it('uses the first configured thickness when legacy groups are merged', () => { + const migrated = migrate({ + segmentGroups: [ + legacyGroup('sg-1', 'ds-ct', [TUMOR]), + legacyGroup('sg-2', 'ds-ct', [EDEMA]), + ], + viewByID: { + Axial: { + config: { + 'sg-1': { + segmentGroup: { outlineOpacity: 1, outlineThickness: 3 }, + }, + 'sg-2': { + segmentGroup: { outlineOpacity: 1, outlineThickness: 7 }, + }, + 'ds-ct': { slice: { slice: 2 } }, + }, + }, + Coronal: { + config: { + 'sg-1': { + segmentGroup: { outlineOpacity: 0.5, outlineThickness: 9 }, + }, + }, + }, + }, + }); + + expect(segmentationFor(migrated, 'ds-ct').outlineThickness).toBe(3); + expect(migrated.viewByID.Axial.config).toEqual({ + 'ds-ct': { slice: { slice: 2 } }, + }); + expect(migrated.viewByID.Coronal.config).toEqual({}); + }); + + it('keeps deferred legacy display settings through schema parsing', () => { + const migrated = migrate({ + segmentGroups: [legacyGroup('sg-blind', 'ds-ct')], + viewByID: { + Axial: { + id: 'Axial', + name: 'Axial', + type: '2D', + config: { + 'sg-blind': { + segmentGroup: { outlineOpacity: 0.25, outlineThickness: 5 }, + }, + }, + }, + }, + }); + + const parsed = ManifestSchema.parse(migrated) as any; + expect(parsed.segmentationArtifacts[0].pendingOutlineOpacity).toBe(0.25); + expect(parsed.segmentations[0].outlineThickness).toBe(5); + }); + + it('maps the active group and value to the matching segment id', () => { + const migrated = migrate({ + segmentGroups: [ + legacyGroup('sg-a', 'ds-ct', [TUMOR, EDEMA]), + legacyGroup('sg-b', 'ds-ct', [TUMOR, EDEMA]), + ], + tools: { + paint: { + activeSegmentGroupID: 'sg-b', + activeSegment: 2, + brushSize: 6, + crossPlaneSync: true, + }, + }, + }); + + const ct = segmentationFor(migrated, 'ds-ct'); + // The selection is a type now, and it is the one that legacy pair became. + expect(migrated.selectedSegment).toBe(boundTo(ct, 'sg-b', 2).segmentId); + + // Identity left the paint block entirely; its own settings survive. + expect(migrated.tools.paint.activeSegmentGroupID).toBeUndefined(); + expect(migrated.tools.paint.activeSegment).toBeUndefined(); + expect(migrated.tools.paint).toMatchObject({ + brushSize: 6, + crossPlaneSync: true, + }); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + it('converts a vector-tool label into one type the shapes share', () => { + const polygon = (imageID: string, slice: number) => ({ + imageID, + frameOfReference: { planeOrigin: [0, 0, slice], planeNormal: [0, 0, 1] }, + slice, + label: 'lbl-tumor', + points: [ + [1, 1, slice], + [5, 1, slice], + [3, 5, slice], + ], + }); + + const migrated = migrate({ + tools: { + polygons: { + tools: [polygon('ds-ct', 3), polygon('ds-mr', 4)], + labels: { + 'lbl-tumor': { labelName: 'Tumor', color: 'red', strokeWidth: 3 }, + }, + }, + }, + }); + + // One type, referenced by both shapes: identity is no longer per image. + const [segmentId] = migrated.segments.map((segment: any) => segment.id); + expect(migrated.segments).toEqual([ + { id: segmentId, name: 'Tumor', color: [255, 0, 0, 255], strokeWidth: 3 }, + ]); + expect( + migrated.tools.polygons.tools.map((tool: any) => tool.segmentId) + ).toEqual([segmentId, segmentId]); + expect(migrated.tools.polygons.labels).toBeUndefined(); + // A label has no voxels, so it brings no per-image mask with it. + expect(migrated.segmentations).toBeUndefined(); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + it('keeps a label no tool used as a type of its own', () => { + const polygon = (imageID: string, slice: number) => ({ + imageID, + label: 'lbl-tumor', + slice, + frameOfReference: { + planeOrigin: [0, 0, slice], + planeNormal: [0, 0, 1], + }, + points: [ + [1, 1, slice], + [5, 1, slice], + [3, 5, slice], + ], + }); + + const migrated = migrate({ + tools: { + polygons: { + tools: [polygon('ds-ct', 3)], + labels: { + 'lbl-tumor': { labelName: 'Tumor', color: 'red', strokeWidth: 3 }, + 'lbl-node': { labelName: 'Node', color: 'blue', strokeWidth: 1 }, + }, + }, + }, + }); + + // Both labels became segments; the picker offered them before and still does. + expect( + migrated.segments.map((segment: any) => [ + segment.name, + segment.strokeWidth, + ]) + ).toEqual([ + ['Tumor', 3], + ['Node', 1], + ]); + const [tumorType] = migrated.segments; + expect(migrated.tools.polygons.tools[0].segmentId).toBe(tumorType.id); + expect(() => ManifestSchema.parse(migrated)).not.toThrow(); + }); + + it('converts CSS label colors to RGBA', () => { + const rectangle = (label: string) => ({ + imageID: 'ds-ct', + frameOfReference: { planeOrigin: [0, 0, 1], planeNormal: [0, 0, 1] }, + slice: 1, + label, + firstPoint: [1, 1, 1], + secondPoint: [4, 4, 1], + }); + + const migrated = migrate({ + tools: { + rectangles: { + tools: [rectangle('lbl-a'), rectangle('lbl-b'), rectangle('lbl-c')], + labels: { + 'lbl-a': { labelName: 'Named', color: 'blue' }, + 'lbl-b': { labelName: 'Hex', color: '#00ff00' }, + 'lbl-c': { labelName: 'Hexa', color: '#0000ff80' }, + }, + }, + }, + }); + + expect( + migrated.segments.map((segment: any) => [segment.name, segment.color]) + ).toEqual([ + ['Named', [0, 0, 255, 255]], + ['Hex', [0, 255, 0, 255]], + ['Hexa', [0, 0, 255, 128]], + ]); + }); + + it('migrates ruler labels into the one segment list', () => { + const migrated = migrate({ + tools: { + rulers: { + tools: [ + { + imageID: 'ds-ct', + frameOfReference: { + planeOrigin: [0, 0, 5], + planeNormal: [0, 0, 1], + }, + slice: 5, + label: 'lbl-long', + firstPoint: [1, 1, 5], + secondPoint: [4, 4, 5], + }, + ], + labels: { 'lbl-long': { labelName: 'Long axis', color: 'red' } }, + }, + }, + }); + + expect(migrated.version).toBe(MANIFEST_VERSION); + expect(migrated.segments).toEqual([ + { + id: expect.any(String), + name: 'Long axis', + color: [255, 0, 0, 255], + }, + ]); + expect(migrated.rulerSegments).toBeUndefined(); + expect(migrated.tools.rulers.labels).toBeUndefined(); + expect(migrated.tools.rulers.tools[0].segmentId).toBe( + migrated.segments[0].id + ); + // Rulers delineate nothing, so no mask is minted for the segment. + expect( + (migrated.segmentations ?? []).flatMap((entry: any) => entry.masks) + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Loaded stage + round trip: a migrated 6.4.0 file restores through the real +// import path, resolves its placeholder extents against the loaded parent, and +// re-saves as 7.0.0 that reloads identically. +// --------------------------------------------------------------------------- + +const DIMENSIONS: [number, number, number] = [4, 4, 2]; +const VOXEL_COUNT = DIMENSIONS[0] * DIMENSIONS[1] * DIMENSIONS[2]; + +function makeImage(fill: (values: Uint8Array) => void = () => {}) { + const image = vtkImageData.newInstance({ spacing: [1, 1, 1] }); + image.setDimensions(DIMENSIONS); + const values = new Uint8Array(VOXEL_COUNT); + fill(values); + image + .getPointData() + .setScalars(vtkDataArray.newInstance({ numberOfComponents: 1, values })); + image.computeTransforms(); + return image; +} + +const seatImage = async (id: string, name: string, image = makeImage()) => { + useImageCacheStore().addVTKImageData(image, name, { id }); + await nextTick(); + return id; +}; + +const legacyScene = () => + JSON.stringify({ + version: '6.4.0', + dataSources: [ + { id: 1, type: 'uri', uri: 'https://ex/ct.nrrd', name: 'CT' }, + { id: 3, type: 'uri', uri: 'https://ex/tumor.seg.nrrd', name: 'Tumor' }, + ], + datasets: [{ id: 'ds-ct', dataSourceId: 1 }], + viewByID: legacyAxialViewConfig('sg-1', false), + segmentGroups: [ + { + id: 'sg-1', + dataSourceId: 3, + metadata: { + name: 'Painted', + parentImage: 'ds-ct', + source: SOURCE, + segments: segmentsBlock([TUMOR, EDEMA]), + }, + }, + ], + tools: { + // No brushSize: setting it needs the app's $paint pinia plugin. + paint: { activeSegmentGroupID: 'sg-1', activeSegment: 2 }, + polygons: { + tools: [ + { + imageID: 'ds-ct', + frameOfReference: { + planeOrigin: [0, 0, 1], + planeNormal: [0, 0, 1], + }, + slice: 1, + label: 'lbl-drawn', + points: [ + [1, 1, 1], + [3, 1, 1], + [2, 3, 1], + ], + }, + ], + labels: { + 'lbl-drawn': { labelName: 'Drawn', color: 'blue' }, + // Declared, never drawn with. + 'lbl-planned': { labelName: 'Planned', color: 'green' }, + }, + }, + }, + }); + +const legacyLocalFileScene = () => { + const manifest = JSON.parse(legacyScene()); + manifest.dataSources[0] = { + id: 1, + type: 'file', + fileId: 10, + fileType: 'application/nrrd', + }; + manifest.datasetFilePath = { '10': 'datasets/10/patient.nrrd' }; + return JSON.stringify(manifest); +}; + +const restoreLegacyScene = async (scene = legacyScene()) => { + const manifest = ManifestSchema.parse(migrateManifest(scene)); + await seatImage('store-ct', 'CT'); + await seatImage( + 'store-tumor', + 'Tumor', + makeImage((values) => { + values.fill(1, 4, 12); + values.fill(2, 12, 20); + }) + ); + await completeStateFileRestore(manifest, [], { + 'ds-ct': 'store-ct', + [leafStateId(3)]: 'store-tumor', + }); + await nextTick(); +}; + +describe('migrated 6.4.0 state file: loaded stage and round trip', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('bounds each migrated segment to the voxels its value covers', async () => { + await restoreLegacyScene(); + + const store = useSegmentationStore(); + const segmentation = store.getSegmentationForImage('store-ct')!; + const bindings = segmentation.order.map( + (id) => segmentation.masks[id].representations.labelmap + ); + // A polygon's type brings no record with it, so this image holds only the + // two masks the group split into. + expect(bindings.map((binding) => binding && [...binding.extent])).toEqual([ + [0, 3, 1, 2, 0, 0], + [0, 3, 0, 3, 0, 1], + ]); + }); + + it('restores the migrated selection and the polygon type', async () => { + await restoreLegacyScene(); + + const store = useSegmentationStore(); + const segments = useSegmentStore().segments; + const segmentation = store.getSegmentationForImage('store-ct')!; + expect(segments.appearanceOf(segments.selectedSegmentId.value).name).toBe( + 'Edema' + ); + expect( + segmentation.order.map( + (id) => segments.appearanceOf(segmentation.masks[id].segmentId).name + ) + ).toEqual(['Tumor', 'Edema']); + + const polygons = usePolygonStore(); + const tool = polygons.toolByID[polygons.toolIDs[0]]; + expect(polygons.appearanceOfTool(tool.id).name).toBe('Drawn'); + }); + + it('restores legacy display state onto the segments and the records', async () => { + await restoreLegacyScene(); + + const segments = useSegmentStore().segments; + const segmentation = + useSegmentationStore().getSegmentationForImage('store-ct')!; + const records = segmentation.order.map((id) => segmentation.masks[id]); + // A legacy group described the thing, so its visibility is the type's. + expect( + records.map((record) => segments.appearanceOf(record.segmentId).visible) + ).toEqual([false, false]); + records.forEach((record) => { + const appearance = segments.appearanceOf(record.segmentId); + expect(appearance.outlineOpacity).toBeCloseTo(0.25); + // Both groups rendered at the legacy 0.4, and that is what the restored + // pair of opacities has to come to. + expect(appearance.fillOpacity * segmentation.fillOpacity).toBeCloseTo( + 0.4 + ); + }); + const drawn = segments.findSegmentByName('Drawn')!; + expect(segments.appearanceOf(drawn.id)).toMatchObject({ + fillOpacity: 1, + outlineOpacity: 1, + }); + expect(segmentation.outlineThickness).toBe(5); + }); + + it('offers a legacy label no tool used as a type with no content', async () => { + await restoreLegacyScene(); + + const segments = usePolygonStore().segments; + expect(segments.segmentList.value.map((segment) => segment.name)).toContain( + 'Planned' + ); + + // Offered, not painted: nothing was drawn with it. + const segmentation = + useSegmentationStore().getSegmentationForImage('store-ct')!; + expect( + segmentation.order.map( + (id) => segments.appearanceOf(segmentation.masks[id].segmentId).name + ) + ).not.toContain('Planned'); + }); + + it('re-saves as 7.0.0 and reloads identically', async () => { + await restoreLegacyScene(legacyLocalFileScene()); + const before = segmentationSnapshot('store-ct'); + expect(before.name).toBe('patient.nrrd'); + + const io = inMemoryArtifactIO(); + const { parsed: saved, stateFiles } = await serializeToStateFiles( + manifestForImages(['store-ct'], { tools: {} }), + io + ); + expect(saved.version).toBe(MANIFEST_VERSION); + expect(saved.segmentGroups).toBeUndefined(); + + setActivePinia(createPinia()); + await seatImage('new-ct', 'CT'); + await useSegmentationStore().deserialize({ + manifest: saved, + stateFiles, + dataIDMap: { 'store-ct': 'new-ct' }, + segmentIdMap: useSegmentStore().deserialize(saved), + io, + }); + await nextTick(); + + expect(segmentationSnapshot('new-ct')).toEqual(before); + }); + + it('keeps colliding legacy identifiers as distinct segments', () => { + // Group 'polygons' value 1 and a polygon label '1' both interpolate to + // 'polygons-1'. + const migrated: any = migrateManifest( + JSON.stringify({ + version: '6.4.0', + datasets: [{ id: 'img-2', dataSourceId: 1 }], + dataSources: [{ id: 1, type: 'uri', uri: '/img-2' }], + segmentGroups: [ + { + id: 'polygons', + path: 'group.vti', + metadata: { + parentImage: 'img-2', + name: 'Group', + segments: { + order: [1], + byValue: { + '1': { value: 1, name: 'Voxels', color: [1, 2, 3, 255] }, + }, + }, + }, + }, + ], + tools: { + polygons: { + labels: { '1': { labelName: 'Vector', color: '#00ff00' } }, + tools: [{ id: 't1', label: '1', imageID: 'img-2' }], + }, + }, + }) + ); + + const segmentIds = migrated.segments.map((segment: any) => segment.id); + // Both sources interpolate to 'polygons-1'; the second is suffixed. + expect(segmentIds).toEqual(['polygons-1', 'polygons-1-2']); + expect(migrated.tools.polygons.tools[0].segmentId).toBe('polygons-1-2'); + // The record that group became keeps an id of its own. + expect(migrated.segmentations[0].masks[0].segmentId).toBe('polygons-1'); + }); +}); diff --git a/src/io/state-file/__tests__/segmentGroupArchivePath.spec.ts b/src/io/state-file/__tests__/segmentGroupArchivePath.spec.ts deleted file mode 100644 index ab31ed0ec..000000000 --- a/src/io/state-file/__tests__/segmentGroupArchivePath.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { makeSegmentGroupArchivePath } from '@/src/io/state-file/segmentGroupArchivePath'; -import { describe, expect, it } from 'vitest'; - -describe('io/state-file/segmentGroupArchivePath', () => { - describe('makeSegmentGroupArchivePath', () => { - it('uses a sanitized segment group stem in the archive path', () => { - const usedPaths = new Set(); - - expect( - makeSegmentGroupArchivePath('Liver: left/right*?', 'vti', usedPaths) - ).to.equal('segmentations/Liver left right.vti'); - }); - - it('deduplicates colliding sanitized names case-insensitively', () => { - const usedPaths = new Set(); - - expect( - makeSegmentGroupArchivePath('Liver/Left', 'vti', usedPaths) - ).to.equal('segmentations/Liver Left.vti'); - expect( - makeSegmentGroupArchivePath('liver:left', 'vti', usedPaths) - ).to.equal('segmentations/liver left (2).vti'); - }); - }); -}); diff --git a/src/io/state-file/__tests__/segmentGroupSource.spec.ts b/src/io/state-file/__tests__/segmentGroupSource.spec.ts deleted file mode 100644 index 38771e534..000000000 --- a/src/io/state-file/__tests__/segmentGroupSource.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - ManifestSchema, - SegmentGroupMetadata, -} from '@/src/io/state-file/schema'; -import { migrateManifest } from '@/src/io/state-file/migrations'; -import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; - -// The optional structured `source` on SegmentGroupMetadata is the durable -// idempotency identity that must round-trip the `.volview.zip`. - -const baseMetadata = { - name: 'Otsu result', - parentImage: 'img-1', - segments: { - order: [1], - byValue: { - '1': { value: 1, name: 'Bin 1', color: [255, 0, 0, 255], visible: true }, - }, - }, -}; - -const metadataWithSource = { - ...baseMetadata, - source: { - providerId: 'analysis-provider', - jobId: 'job-abc', - outputId: 'outputLabelmap', - }, -}; - -describe('SegmentGroupMetadata.source', () => { - it('accepts and round-trips structured provenance', () => { - const parsed = SegmentGroupMetadata.parse(metadataWithSource); - expect(parsed.source).toEqual(metadataWithSource.source); - }); - - it('is optional — a hand-painted group without source still validates', () => { - expect(() => SegmentGroupMetadata.parse(baseMetadata)).not.toThrow(); - expect(SegmentGroupMetadata.parse(baseMetadata).source).toBeUndefined(); - }); - - it('rejects a source missing one identity component', () => { - const bad = { - ...metadataWithSource, - source: { - providerId: 'analysis-provider', - jobId: 'job-abc', - }, - }; - expect(SegmentGroupMetadata.safeParse(bad).success).toBe(false); - }); - - it('survives a full manifest parse (round-trips the .volview.zip)', () => { - const manifest = { - version: MANIFEST_VERSION, - dataSources: [], - segmentGroups: [ - { id: 'sg-1', dataSourceId: 7, metadata: metadataWithSource }, - ], - }; - const parsed = ManifestSchema.parse(manifest); - expect(parsed.segmentGroups?.[0].metadata.source).toEqual( - metadataWithSource.source - ); - }); -}); - -describe('manifest version / migration bump', () => { - // Annotation provenance is additive to the structured segment-group source - // already covered by 6.4.0, so it needs no stamp-only version bump. - it('keeps MANIFEST_VERSION at 6.4.0', () => { - expect(MANIFEST_VERSION).toBe('6.4.0'); - }); - - it('migrates a 6.3.0 manifest to the current version, preserving segment groups', () => { - const old = JSON.stringify({ - version: '6.3.0', - dataSources: [], - segmentGroups: [ - { - id: 'sg-1', - dataSourceId: 7, - metadata: { - name: 'Painted', - parentImage: 'img-1', - segments: { order: [], byValue: {} }, - }, - }, - ], - }); - const migrated = migrateManifest(old); - expect(migrated.version).toBe(MANIFEST_VERSION); - expect(migrated.segmentGroups).toHaveLength(1); - // An old manifest lacking `source` still validates (additive-optional). - expect(() => ManifestSchema.parse(migrated)).not.toThrow(); - }); -}); diff --git a/src/io/state-file/__tests__/segmentationArtifactSource.spec.ts b/src/io/state-file/__tests__/segmentationArtifactSource.spec.ts new file mode 100644 index 000000000..82c6c2f4d --- /dev/null +++ b/src/io/state-file/__tests__/segmentationArtifactSource.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; + +import { + Segment, + ManifestSchema, + Segmentation, + SegmentationArtifact, +} from '@/src/io/state-file/schema'; +import { MANIFEST_VERSION } from '@/src/io/state-file/serialize'; + +// Ported from segmentGroupSource.spec.ts. Provenance now rides on the artifact +// record, which is where persistent labelmap identity lives in the 7.0.0 wire +// schema; segment identity lives on the segmentation. + +const baseArtifact = { + id: 'artifact-1', + parentImage: 'img-1', + name: 'Otsu result', + path: 'segmentations/Otsu result.vti', +}; + +const artifactWithSource = { + ...baseArtifact, + source: { + providerId: 'analysis-provider', + jobId: 'job-abc', + outputId: 'outputLabelmap', + }, +}; + +const segmentation = { + id: 'segmentation-1', + name: 'CT Chest', + parentImage: 'img-1', + masks: [ + { + id: 'segment-1', + segmentId: 'segment-1', + visible: true, + locked: false, + representations: { + labelmap: { + path: 'mask.vti', + extent: [0, 3, 0, 3, 0, 1], + }, + }, + }, + ], + order: ['segment-1'], +}; + +const segments = [ + { + id: 'segment-1', + name: 'Bin 1', + color: [255, 0, 0, 255], + visible: true, + locked: false, + }, +]; + +describe('SegmentationArtifact.source', () => { + it('accepts and round-trips structured provenance', () => { + const parsed = SegmentationArtifact.parse(artifactWithSource); + expect(parsed.source).toEqual(artifactWithSource.source); + }); + + it('is optional for a hand-painted artifact without source', () => { + expect(() => SegmentationArtifact.parse(baseArtifact)).not.toThrow(); + expect(SegmentationArtifact.parse(baseArtifact).source).toBeUndefined(); + }); + + it('rejects a source missing one identity component', () => { + const bad = { + ...artifactWithSource, + source: { providerId: 'analysis-provider', jobId: 'job-abc' }, + }; + expect(SegmentationArtifact.safeParse(bad).success).toBe(false); + }); + + it('requires either an archive path or a dataSourceId', () => { + const { id, parentImage, name } = baseArtifact; + const withoutPath = { id, parentImage, name }; + expect(SegmentationArtifact.safeParse(withoutPath).success).toBe(false); + expect( + SegmentationArtifact.safeParse({ ...withoutPath, dataSourceId: 7 }) + .success + ).toBe(true); + }); + + it('survives a full manifest parse (round-trips the .volview.zip)', () => { + const manifest = { + version: MANIFEST_VERSION, + dataSources: [], + segmentationArtifacts: [artifactWithSource], + segmentations: [segmentation], + segments, + }; + const parsed = ManifestSchema.parse(manifest) as any; + expect(parsed.segmentationArtifacts[0].source).toEqual( + artifactWithSource.source + ); + expect(parsed.segments).toEqual(segments); + expect(parsed.segmentations[0].masks[0].segmentId).toBe('segment-1'); + }); +}); + +describe('Segmentation wire shape', () => { + it('carries the mask id, its type and the order', () => { + const parsed = Segmentation.parse(segmentation); + expect(parsed.masks[0].id).toBe('segment-1'); + expect(parsed.masks[0].segmentId).toBe('segment-1'); + expect(parsed.masks[0].representations.labelmap).toEqual({ + path: 'mask.vti', + extent: [0, 3, 0, 3, 0, 1], + }); + expect(parsed.order).toEqual(['segment-1']); + }); + + it('defaults a type to visible and unlocked', () => { + const parsed = Segment.parse({ + id: 'segment-1', + name: 'Bin 1', + color: [255, 0, 0, 255], + }); + + expect(parsed.visible).toBe(true); + expect(parsed.locked).toBe(false); + }); + + it('allows a mask with no labelmap binding', () => { + const parsed = Segmentation.parse({ + ...segmentation, + masks: [{ id: 'mask-1', segmentId: 'segment-1', representations: {} }], + }); + expect(parsed.masks[0].representations.labelmap).toBeUndefined(); + }); + + it('rejects a mask that names no segment', () => { + const parsed = Segmentation.safeParse({ + ...segmentation, + masks: [{ id: 'mask-1', representations: {} }], + }); + expect(parsed.success).toBe(false); + }); +}); + +describe('paint wire block', () => { + it('no longer carries a segment group id or a label value', () => { + const parsed = ManifestSchema.parse({ + version: MANIFEST_VERSION, + dataSources: [], + tools: { + paint: { + activeSegmentGroupID: 'sg-1', + activeSegment: 3, + brushSize: 5, + crossPlaneSync: true, + }, + }, + }); + expect(parsed.tools?.paint).toEqual({ brushSize: 5, crossPlaneSync: true }); + }); +}); + +describe('manifest version', () => { + // The segment model replaces `segmentGroups` with `segmentations` plus + // `segmentationArtifacts`, including display state in that breaking change. + it('pins MANIFEST_VERSION at 7.0.0', () => { + expect(MANIFEST_VERSION).toBe('7.0.0'); + }); +}); diff --git a/src/io/state-file/__tests__/segmentationDisplayRoundTrip.spec.ts b/src/io/state-file/__tests__/segmentationDisplayRoundTrip.spec.ts new file mode 100644 index 000000000..fab07443c --- /dev/null +++ b/src/io/state-file/__tests__/segmentationDisplayRoundTrip.spec.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + seatSpecImage as seatImage, + inMemoryArtifactIO, + mintSegment, + manifestForImages, + serializeToStateFiles, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { nextTick } from 'vue'; +import JSZip from 'jszip'; + +import { ManifestSchema } from '@/src/io/state-file/schema'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { DEFAULT_SEGMENTATION_FILL_OPACITY } from '@/src/segmentation/model'; + +// --------------------------------------------------------------------------- +// Display state on the wire: the per-image multipliers ride on the +// segmentation, the per-segment opacities on the type, and a manifest that +// states neither comes back with the app defaults. +// --------------------------------------------------------------------------- + +const store = () => useSegmentationStore(); + +const baseManifest = () => manifestForImages(['img-1']); + +/** A scene whose display state is nowhere near the defaults. */ +function buildScene() { + const segmentation = store().ensureSegmentationForImage('img-1'); + const segments = useSegmentStore().segments; + const tumorType = mintSegment({ + name: 'Tumor', + fillOpacity: 0.25, + outlineOpacity: 0.75, + }); + const tumor = store().createMask(segmentation.id, tumorType); + store().ensureLabelmapBinding(tumor.id); + const plannedType = mintSegment({ name: 'Planned', fillOpacity: 0 }); + store().createMask(segmentation.id, plannedType); + + const model = store().segmentations[segmentation.id]; + model.fillOpacity = 0.5; + model.outlineOpacity = 0.125; + model.outlineThickness = 5; + return { segments, tumorType, plannedType }; +} + +const opacitiesOf = (segmentIds: string[]) => { + const segments = useSegmentStore().segments; + return { + fill: segmentIds.map((id) => segments.appearanceOf(id).fillOpacity), + outline: segmentIds.map((id) => segments.appearanceOf(id).outlineOpacity), + }; +}; + +// A 7.0.0 manifest that states no display state anywhere. +const manifest700 = () => ({ + version: '7.0.0', + dataSources: [], + segments: [{ id: 't-1', name: 'Tumor', color: [255, 0, 0, 255] }], + segmentations: [ + { + id: 'seg-1', + name: 'CT', + parentImage: 'img-1', + masks: [{ id: 's-1', segmentId: 't-1', representations: {} }], + order: ['s-1'], + }, + ], +}); + +describe('segmentation display state on the wire', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + }); + + it('serializes the per-image multipliers and the per-type opacities', async () => { + buildScene(); + + const zip = new JSZip(); + const manifest = baseManifest(); + useSegmentStore().serialize({ zip, manifest }); + await store().serialize({ zip, manifest }, inMemoryArtifactIO()); + + const parsed = ManifestSchema.parse(manifest); + const wire = parsed.segmentations![0]; + expect(wire.fillOpacity).toBe(0.5); + expect(wire.outlineOpacity).toBe(0.125); + expect(wire.outlineThickness).toBe(5); + const segmentById = new Map( + parsed.segments!.map((segment) => [segment.id, segment]) + ); + expect( + wire.masks.map( + (segment) => segmentById.get(segment.segmentId)!.fillOpacity + ) + ).toEqual([0.25, 0]); + expect( + wire.masks.map( + (segment) => segmentById.get(segment.segmentId)!.outlineOpacity + ) + ).toEqual([0.75, undefined]); + }); + + it('restores display state through a save and load', async () => { + buildScene(); + + const io = inMemoryArtifactIO(); + const { parsed, stateFiles } = await serializeToStateFiles( + baseManifest(), + io + ); + + setActivePinia(createPinia()); + await seatImage('new-1'); + await store().deserialize({ + manifest: parsed, + stateFiles: stateFiles, + dataIDMap: { 'img-1': 'new-1' }, + segmentIdMap: useSegmentStore().deserialize(parsed), + io: io, + }); + await nextTick(); + + const restored = store().getSegmentationForImage('new-1')!; + expect(restored.fillOpacity).toBe(0.5); + expect(restored.outlineOpacity).toBe(0.125); + expect(restored.outlineThickness).toBe(5); + const opacities = opacitiesOf( + restored.order.map((id) => restored.masks[id].segmentId) + ); + expect(opacities.fill).toEqual([0.25, 0]); + expect(opacities.outline).toEqual([0.75, 1]); + }); + + it('fills defaults for a manifest that carries no display state', () => { + const parsed = ManifestSchema.parse(manifest700()); + const wire = parsed.segmentations![0]; + + expect(wire.fillOpacity).toBe(DEFAULT_SEGMENTATION_FILL_OPACITY); + expect(wire.outlineOpacity).toBe(1); + expect(wire.outlineThickness).toBe(2); + // Absent on a segment means the app default, supplied by the resolver. + expect(parsed.segments![0].fillOpacity).toBeUndefined(); + expect(parsed.segments![0].outlineOpacity).toBeUndefined(); + }); + + it('restores a 7.0.0 manifest with default display state', async () => { + const parsed = ManifestSchema.parse(manifest700()); + + await store().deserialize({ + manifest: parsed, + stateFiles: [], + dataIDMap: { 'img-1': 'img-1' }, + segmentIdMap: useSegmentStore().deserialize(parsed), + io: inMemoryArtifactIO(), + }); + await nextTick(); + + const restored = store().getSegmentationForImage('img-1')!; + expect(restored.fillOpacity).toBe(DEFAULT_SEGMENTATION_FILL_OPACITY); + expect(restored.outlineOpacity).toBe(1); + expect(restored.outlineThickness).toBe(2); + const segment = restored.masks[restored.order[0]]; + const opacities = opacitiesOf([segment.segmentId]); + expect(opacities.fill).toEqual([1]); + expect(opacities.outline).toEqual([1]); + }); +}); diff --git a/src/io/state-file/__tests__/segmentationRoundTrip.spec.ts b/src/io/state-file/__tests__/segmentationRoundTrip.spec.ts new file mode 100644 index 000000000..aa9153e3c --- /dev/null +++ b/src/io/state-file/__tests__/segmentationRoundTrip.spec.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + seatSpecImage as seatImage, + inMemoryArtifactIO, + mintSegment, + segmentOfMask, + manifestForImages, + segmentationSnapshot, + serializeToStateFiles, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { nextTick } from 'vue'; +import JSZip from 'jszip'; + +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; + +// --------------------------------------------------------------------------- +// The 7.0.0 wire schema round trip: a scene serializes to `segmentations` + +// `segmentationArtifacts` and restores into fresh stores with the same +// segments, order, active segment and artifact provenance. SegmentMask identity is +// never re-derived from label values, so duplicate names across images survive +// as distinct segments and a segment with no storage survives as one. +// --------------------------------------------------------------------------- + +const SOURCE = { + providerId: 'analysis-provider', + jobId: 'job-abc', + outputId: 'outputLabelmap', +}; + +const selectedTypeSummary = () => { + const segments = useSegmentStore().segments; + const segmentId = segments.selectedSegmentId.value; + if (!segmentId) return undefined; + const store = useSegmentationStore(); + return { + // The type is image-independent; this is where it currently has a mask. + parentImages: Object.values(store.segmentations) + .filter((segmentation) => + Object.values(segmentation.masks).some( + (segment) => segment.segmentId === segmentId + ) + ) + .map((segmentation) => segmentation.parentImageId), + name: segments.appearanceOf(segmentId).name, + }; +}; + +async function buildScene() { + await seatImage('img-1', 'CT A'); + await seatImage('img-2', 'CT B'); + const store = useSegmentationStore(); + + const first = store.ensureSegmentationForImage('img-1'); + // No binding: a segment created by "add" has no voxels until a first edit. + const planned = store.createMask(first.id, mintSegment({ name: 'Planned' })); + useSegmentStore().segments.updateSegment(segmentOfMask(planned.id), { + locked: true, + visible: false, + }); + const tumor = store.createMask(first.id, mintSegment({ name: 'Tumor' })); + store.ensureLabelmapBinding(tumor.id).source = SOURCE; + + // Same name on another image: still a distinct segment. + const second = store.ensureSegmentationForImage('img-2'); + const otherTumor = store.createMask( + second.id, + mintSegment({ name: 'Tumor' }) + ); + store.ensureLabelmapBinding(otherTumor.id); + + useSegmentStore().segments.selectSegment(store.getMask(tumor.id).segmentId); + await nextTick(); + return { store }; +} + +describe('segmentation state-file round trip', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('restores segments, order, active segment and artifact provenance', async () => { + await buildScene(); + + const io = inMemoryArtifactIO(); + const before = { + first: segmentationSnapshot('img-1'), + second: segmentationSnapshot('img-2'), + selected: selectedTypeSummary(), + }; + const { parsed, stateFiles } = await serializeToStateFiles( + manifestForImages(['img-1', 'img-2']), + io + ); + expect(parsed.segmentations).toHaveLength(2); + // Every labelmap a save writes belongs to a mask, so the artifact array + // that a migration or a backend fills is empty here. + expect(parsed.segmentationArtifacts).toBeUndefined(); + expect(parsed.segmentGroups).toBeUndefined(); + + setActivePinia(createPinia()); + await seatImage('new-1', 'CT A'); + await seatImage('new-2', 'CT B'); + const segmentIdMap = useSegmentStore().deserialize(parsed); + await useSegmentationStore().deserialize({ + manifest: parsed, + stateFiles: stateFiles, + dataIDMap: { 'img-1': 'new-1', 'img-2': 'new-2' }, + segmentIdMap: segmentIdMap, + io: io, + }); + await nextTick(); + + expect(segmentationSnapshot('new-1')).toEqual(before.first); + expect(segmentationSnapshot('new-2')).toEqual(before.second); + expect(selectedTypeSummary()).toEqual({ + parentImages: ['new-1'], + name: before.selected!.name, + }); + }); + + it('keeps the unbound segment unbound and does not allocate storage for it', async () => { + await buildScene(); + + const io = inMemoryArtifactIO(); + const zip = new JSZip(); + const manifest = manifestForImages(['img-1']); + + // The registry writes before the records that reference it, as the app's + // serializer order does. + useSegmentStore().serialize({ zip, manifest }); + await useSegmentationStore().serialize({ zip, manifest }, io); + + const wire = (manifest as any).segmentations.find( + (segmentation: any) => segmentation.parentImage === 'img-1' + ); + const segmentIdByName = Object.fromEntries( + (manifest as any).segments.map((type: any) => [type.name, type.id]) + ); + const planned = wire.masks.find( + (segment: any) => segment.segmentId === segmentIdByName.Planned + ); + expect(planned.representations.labelmap).toBeUndefined(); + // Lock and visibility ride on the type, so the record carries neither. + const plannedType = (manifest as any).segments.find( + (segment: any) => segment.id === segmentIdByName.Planned + ); + expect(plannedType).toMatchObject({ locked: true, visible: false }); + // One archive entry per bound mask, and none for the unbound one. + expect( + wire.masks.flatMap((segment: any) => + segment.representations.labelmap + ? [segment.representations.labelmap.path] + : [] + ) + ).toHaveLength(1); + }); +}); diff --git a/src/io/state-file/__tests__/serializeResilience.spec.ts b/src/io/state-file/__tests__/serializeResilience.spec.ts index 11f3e7bae..d3be66e8c 100644 --- a/src/io/state-file/__tests__/serializeResilience.spec.ts +++ b/src/io/state-file/__tests__/serializeResilience.spec.ts @@ -10,26 +10,12 @@ const writeDatasets = (stateFile: StateFile) => { stateFile.manifest.dataSources = [{ id: 1, type: 'uri', uri: '/dataset-1' }]; }; -const writeOneInvalidGroup = async (stateFile: StateFile) => { - stateFile.manifest.segmentGroups = reactive([ - { - id: 'valid-group', - dataSourceId: 1, - metadata: { - name: 'Valid group', - parentImage: 'dataset-1', - segments: { order: [], byValue: {} }, - }, - }, - { - id: 'invalid-group', - metadata: { - name: 'Invalid group', - parentImage: 'dataset-1', - segments: { order: [], byValue: {} }, - }, - } as never, - ]) as never; +const writeOneInvalidMask = async (stateFile: StateFile) => { + stateFile.zip.file('valid.vti', 'bytes'); + (stateFile.manifest as any).segmentations = reactive([ + segmentationWithPath('valid.vti'), + { id: 'invalid', name: 'Invalid segmentation' }, + ]); }; const recordWarnings = () => { @@ -56,18 +42,37 @@ const manifestWithSelection = (primarySelection: string): Manifest => ({ primarySelection, }); +const segmentationWithPath = (path: string) => ({ + id: 'seg-1', + name: 'Seg', + parentImage: 'dataset-1', + order: ['mask-1'], + masks: [ + { + id: 'mask-1', + segmentId: 'segment-1', + representations: { + labelmap: { path, extent: [0, 1, 0, 1, 0, 1] }, + }, + }, + ], +}); + describe('state-file serialization resilience', () => { it('writes a restorable zip when one manifest entry is malformed', async () => { const sink = recordWarnings(); const blob = await serialize({ - writers: [writeDatasets, writeOneInvalidGroup], + writers: [writeDatasets, writeOneInvalidMask], addWarning: sink.addWarning, }); const zip = await JSZip.loadAsync(blob); const manifest = JSON.parse(await zip.file(MANIFEST)!.async('string')); - expect(manifest.segmentGroups).toHaveLength(1); - expect(manifest.segmentGroups[0].id).toBe('valid-group'); + expect(manifest.segmentationArtifacts).toBeUndefined(); + expect(manifest.segmentations).toHaveLength(1); + expect( + manifest.segmentations[0].masks[0].representations.labelmap.path + ).toBe('valid.vti'); expect(sink.warnings).toEqual([ { title: 'Some session content could not be saved', @@ -88,31 +93,35 @@ describe('state-file serialization resilience', () => { ); }); - it('omits invalid optional dependents and their archive members', () => { - const zip = new JSZip(); - zip.file('segmentations/orphan.vti', 'bytes'); - const manifest: Manifest = { - version: MANIFEST_VERSION, - datasets: [{ id: 'dataset-1', dataSourceId: 1 }], - dataSources: [{ id: 1, type: 'uri', uri: '/dataset-1' }], - segmentGroups: [ - { - id: 'orphan', - path: 'segmentations/orphan.vti', - metadata: { name: 'Orphan', parentImage: 'missing-dataset' }, - }, + it('omits a segmentation and layer relationship with missing parents', () => { + const manifest = { + ...manifestWithSelection('dataset-1'), + segmentations: [ + { ...segmentationWithPath('mask.vti'), parentImage: 'missing' }, ], parentToLayers: [ - { selectionKey: 'dataset-1', sourceSelectionKeys: ['missing-layer'] }, + { selectionKey: 'dataset-1', sourceSelectionKeys: ['missing'] }, ], - }; - - const normalized = normalizeManifest(manifest, zip); - expect(normalized.manifest.segmentGroups).toEqual([]); + } as unknown as Manifest; + const normalized = normalizeManifest(manifest, new JSZip()); + expect(normalized.manifest.segmentations).toEqual([]); expect(normalized.manifest.parentToLayers).toEqual([]); - expect(zip.file('segmentations/orphan.vti')).toBeNull(); - expect(normalized.omitted.join('\n')).toMatch( - /parent dataset|layer relationship/ + expect(normalized.omitted.join('\n')).toMatch(/parent dataset/); + }); + + it('reports a missing mask file without losing its segment identity', () => { + const manifest = { + ...manifestWithSelection('dataset-1'), + segmentations: [segmentationWithPath('missing.vti')], + } as unknown as Manifest; + const normalized = normalizeManifest(manifest, new JSZip()); + expect(normalized.manifest.segmentations![0].masks[0]).toMatchObject({ + id: 'mask-1', + segmentId: 'segment-1', + representations: {}, + }); + expect(normalized.omitted.join('\n')).toContain( + 'archive member missing.vti is missing' ); }); @@ -193,39 +202,80 @@ describe('state-file serialization resilience', () => { warnSpy.mockRestore(); }); - it('round-trips a locked segment mask', () => { - const manifest: Manifest = { + it('round-trips a locked record, its registry and the selection', () => { + const manifest = { version: MANIFEST_VERSION, datasets: [{ id: 'dataset-1', dataSourceId: 1 }], dataSources: [{ id: 1, type: 'uri', uri: '/dataset-1' }], - segmentGroups: [ + segmentations: [ { - id: 'group-1', - dataSourceId: 1, - metadata: { - name: 'Group', - parentImage: 'dataset-1', - segments: { - order: [1], - byValue: { - '1': { - value: 1, - name: 'Segment 1', - color: [255, 0, 0, 255], - visible: true, - locked: true, + id: 'segmentation-1', + name: 'CT', + parentImage: 'dataset-1', + masks: [ + { + id: 'segment-1', + segmentId: 'segment-1', + representations: { + labelmap: { + path: 'mask.vti', + extent: [0, 3, 0, 3, 0, 3], }, }, }, - }, + ], + order: ['segment-1'], }, ], - }; + segments: [ + { + id: 'segment-1', + name: 'Segment 1', + color: [255, 0, 0, 255], + visible: true, + locked: true, + }, + ], + selectedSegment: 'segment-1', + } as unknown as Manifest; - const normalized = normalizeManifest(manifest, new JSZip()); + const zip = new JSZip(); + zip.file('mask.vti', 'bytes'); + const normalized = normalizeManifest(manifest, zip) as any; + const segmentation = normalized.manifest.segmentations[0]; + expect(segmentation.masks[0].segmentId).toBe('segment-1'); + expect(segmentation.masks[0].representations.labelmap).toEqual({ + path: 'mask.vti', + extent: [0, 3, 0, 3, 0, 3], + }); + // The registry and the selection survive normalization beside the records. + // Lock rides on the type, so the record is storage and nothing else. + expect(normalized.manifest.segments).toEqual([ + { + id: 'segment-1', + name: 'Segment 1', + color: [255, 0, 0, 255], + visible: true, + locked: true, + }, + ]); + expect(normalized.manifest.selectedSegment).toBe('segment-1'); + }); + + it('saves no import instructions', () => { + const manifest = { + ...manifestWithSelection('dataset-1'), + segmentationArtifacts: [ + { + id: 'input', + parentImage: 'dataset-1', + name: 'Input', + dataSourceId: 1, + }, + ], + }; expect( - normalized.manifest.segmentGroups?.[0].metadata.segments?.byValue['1'] - .locked - ).toBe(true); + normalizeManifest(manifest, new JSZip()).manifest + ).not.toHaveProperty('segmentationArtifacts'); }); }); diff --git a/src/io/state-file/dataSourceDisplayName.ts b/src/io/state-file/dataSourceDisplayName.ts new file mode 100644 index 000000000..ee9e5dd7d --- /dev/null +++ b/src/io/state-file/dataSourceDisplayName.ts @@ -0,0 +1,53 @@ +import { getURLBasename } from '@/src/utils'; +import { basename } from '@/src/utils/path'; +import type { DataSourceType } from '@/src/io/state-file/schema'; + +export const dataSourcesById = ( + sources: DataSourceType[] +): Record => + Object.fromEntries(sources.map((source) => [source.id, source])); + +const leafDataSourceDisplayNames = ( + source: Exclude, + datasetFilePath: Record | undefined +) => { + if (source.type === 'uri') { + return [source.name ?? getURLBasename(source.uri) ?? source.uri]; + } + if (source.type === 'file') { + const path = datasetFilePath?.[source.fileId]; + return path ? [basename(path)] : []; + } + return [basename(source.path)]; +}; + +const dataSourceDisplayNames = ( + id: number, + byId: Record, + datasetFilePath: Record | undefined, + visiting = new Set() +): string[] => { + if (visiting.has(id)) return []; + const source = byId[id]; + if (!source) return []; + + const nextVisiting = new Set(visiting).add(id); + if (source.type !== 'collection') { + return leafDataSourceDisplayNames(source, datasetFilePath); + } + return source.sources.flatMap((sourceId) => + dataSourceDisplayNames(sourceId, byId, datasetFilePath, nextVisiting) + ); +}; + +export const summarizeDataSource = ( + id: number, + byId: Record, + datasetFilePath: Record | undefined, + fallback: string +) => { + const names = [...new Set(dataSourceDisplayNames(id, byId, datasetFilePath))]; + if (names.length === 0) return fallback; + if (names.length <= 3) return names.join(', '); + return `${names.slice(0, 2).join(', ')}, … (${names.length} files)`; +}; diff --git a/src/io/state-file/segmentGroupArchivePath.ts b/src/io/state-file/maskArchivePath.ts similarity index 50% rename from src/io/state-file/segmentGroupArchivePath.ts rename to src/io/state-file/maskArchivePath.ts index a907f9d2a..6dc52ab25 100644 --- a/src/io/state-file/segmentGroupArchivePath.ts +++ b/src/io/state-file/maskArchivePath.ts @@ -1,12 +1,12 @@ import { normalize } from '@/src/utils/path'; import { sanitizeFileStem } from '@/src/io/fileName'; -export const DEFAULT_SEGMENT_GROUP_ARCHIVE_STEM = 'Segment Group'; -const SEGMENT_GROUP_ARCHIVE_DIR = 'segmentations'; +export const DEFAULT_MASK_ARCHIVE_STEM = 'Segment Group'; +const MASK_ARCHIVE_DIR = 'segmentations'; -export function sanitizeSegmentGroupFileStem( +export function sanitizeSegmentationFileStem( name: string, - fallback = DEFAULT_SEGMENT_GROUP_ARCHIVE_STEM + fallback = DEFAULT_MASK_ARCHIVE_STEM ) { return sanitizeFileStem(name, fallback); } @@ -15,20 +15,18 @@ function makeArchivePathKey(path: string) { return normalize(path).toLowerCase(); } -export function makeSegmentGroupArchivePath( +export function makeMaskArchivePath( name: string, extension: string, usedPaths: Set ) { - const stem = sanitizeSegmentGroupFileStem(name); + const stem = sanitizeSegmentationFileStem(name); let index = 1; - let path = normalize(`${SEGMENT_GROUP_ARCHIVE_DIR}/${stem}.${extension}`); + let path = normalize(`${MASK_ARCHIVE_DIR}/${stem}.${extension}`); while (usedPaths.has(makeArchivePathKey(path))) { index += 1; - path = normalize( - `${SEGMENT_GROUP_ARCHIVE_DIR}/${stem} (${index}).${extension}` - ); + path = normalize(`${MASK_ARCHIVE_DIR}/${stem} (${index}).${extension}`); } usedPaths.add(makeArchivePathKey(path)); diff --git a/src/io/state-file/migrations.ts b/src/io/state-file/migrations.ts index 12588de17..38c37bee8 100644 --- a/src/io/state-file/migrations.ts +++ b/src/io/state-file/migrations.ts @@ -1,4 +1,11 @@ import { pipe } from '@/src/utils/functional'; +import { emptyExtent } from '@/src/segmentation/geometry'; +import { cssColorToRGBA } from '@/src/segmentation/color'; +import { + dataSourcesById, + summarizeDataSource, +} from '@/src/io/state-file/dataSourceDisplayName'; +import type { DataSourceType } from '@/src/io/state-file/schema'; const migrateOrPass = (versions: Array, migrationFunc: (manifest: any) => any) => @@ -164,6 +171,377 @@ const migrate630To640 = (inputManifest: any) => ({ version: '6.4.0', }); +// A manifest saved before `datasets` existed lets every uri source stand in for +// one, keyed by its stringified source id, matching `manifestDatasets`. +const datasetDisplayName = (manifest: any, datasetId: string) => { + const sources: DataSourceType[] = Array.isArray(manifest.dataSources) + ? manifest.dataSources + : []; + const datasets: any[] = Array.isArray(manifest.datasets) + ? manifest.datasets + : sources + .filter((source) => source.type === 'uri') + .map((source) => ({ id: String(source.id), dataSourceId: source.id })); + const dataset = datasets.find((entry) => entry.id === datasetId); + if (!dataset) return datasetId; + return summarizeDataSource( + dataset.dataSourceId, + dataSourcesById(sources), + manifest.datasetFilePath, + datasetId + ); +}; + +// Descriptor values in `order`, then any byValue entry `order` forgot. +const descriptorValues = (descriptors: any) => { + const byValue = descriptors?.byValue ?? {}; + const ordered: number[] = ( + Array.isArray(descriptors?.order) ? descriptors.order : [] + ).filter((value: number) => String(value) in byValue); + const rest = Object.keys(byValue) + .map(Number) + .filter((value) => !ordered.includes(value)) + .sort((a, b) => a - b); + return [...ordered, ...rest]; +}; + +const numberOrUndefined = (value: unknown) => + typeof value === 'number' ? value : undefined; + +const booleanOrUndefined = (value: unknown) => + typeof value === 'boolean' ? value : undefined; + +const legacyViewGroupDisplay = (view: any, groupId: string) => { + const config = view?.config?.[groupId]; + const blend = config?.layers?.blendConfig; + const outline = config?.segmentGroup; + return { + fillOpacity: numberOrUndefined(blend?.opacity), + visible: booleanOrUndefined(blend?.visibility), + outlineOpacity: numberOrUndefined(outline?.outlineOpacity), + outlineThickness: numberOrUndefined(outline?.outlineThickness), + }; +}; + +// A 6.4.0 group rendered at the layer opacity default unless a view saved one. +const LEGACY_GROUP_FILL_OPACITY_DEFAULT = 0.3; + +const legacyFillOpacity = ( + display: ReturnType +) => display.fillOpacity ?? LEGACY_GROUP_FILL_OPACITY_DEFAULT; + +// A zero on the segmentation leaves nothing for a segment to be a share of, and +// every group under it was hidden anyway. +const fillShareOf = ( + display: ReturnType, + parentFill: number +) => (parentFill === 0 ? 1 : legacyFillOpacity(display) / parentFill); + +const legacyGroupDisplay = (manifest: any, groupId: string) => { + // These controls were synchronized across 2D views. Read the first value + // each view supplies so a partially populated view does not hide another. + return Object.values(manifest.viewByID ?? {}) + .map((view) => legacyViewGroupDisplay(view, groupId)) + .reduce( + (display, next) => ({ + fillOpacity: display.fillOpacity ?? next.fillOpacity, + visible: display.visible ?? next.visible, + outlineOpacity: display.outlineOpacity ?? next.outlineOpacity, + outlineThickness: display.outlineThickness ?? next.outlineThickness, + }), + {} as ReturnType + ); +}; + +const migrateLegacyDisplay = (manifest: any) => { + const displayByArtifact = new Map< + string, + ReturnType + >(); + const outlineThicknessByParent = new Map(); + const fillByParent = new Map(); + const artifacts: any[] = Array.isArray(manifest.segmentationArtifacts) + ? manifest.segmentationArtifacts + : []; + + artifacts.forEach((artifact) => { + const display = legacyGroupDisplay(manifest, artifact.id); + displayByArtifact.set(artifact.id, display); + if ( + !outlineThicknessByParent.has(artifact.parentImage) && + display.outlineThickness !== undefined + ) { + // Several legacy groups can collapse into one segmentation. The new + // model has one thickness for it, so the first configured group in + // artifact order deterministically supplies that shared value. + outlineThicknessByParent.set( + artifact.parentImage, + display.outlineThickness + ); + } + // Fill is a product of the segmentation's opacity and the segment's, and a + // legacy group's opacity has to survive as that product. The largest of the + // parent's groups goes on the segmentation, so every group's share of it + // stays a fraction the per-segment slider can hold. + fillByParent.set( + artifact.parentImage, + Math.max( + fillByParent.get(artifact.parentImage) ?? 0, + legacyFillOpacity(display) + ) + ); + }); + + const parentFillOf = (parentImage: string) => + fillByParent.get(parentImage) ?? LEGACY_GROUP_FILL_OPACITY_DEFAULT; + + const segmentById = new Map( + (Array.isArray(manifest.segments) ? manifest.segments : []).map( + (type: any) => [type.id, type] + ) + ); + + const segmentations: any[] = Array.isArray(manifest.segmentations) + ? manifest.segmentations + : []; + segmentations.forEach((segmentation) => { + if ( + segmentation.outlineThickness === undefined && + outlineThicknessByParent.has(segmentation.parentImage) + ) { + segmentation.outlineThickness = outlineThicknessByParent.get( + segmentation.parentImage + ); + } + + const parentFill = parentFillOf(segmentation.parentImage); + segmentation.fillOpacity = parentFill; + + // A legacy group described what it showed, so its opacity and its + // visibility both land on the type the group became. + (Array.isArray(segmentation.masks) ? segmentation.masks : []).forEach( + (segment: any) => { + const artifactId = segment.representations?.labelmap?.artifactId; + const display = displayByArtifact.get(artifactId); + const type = segmentById.get(segment.segmentId); + if (!display || !type) return; + type.fillOpacity = fillShareOf(display, parentFill); + if (display.outlineOpacity !== undefined) { + type.outlineOpacity = display.outlineOpacity; + } + if (display.visible !== undefined) { + type.visible = (type.visible ?? true) && display.visible; + } + } + ); + }); + + // Only a group awaiting its decode needs these: it names no segments, so + // there is no type for the loop above to have put its display on. + artifacts.forEach((artifact) => { + if (!artifact.pendingDecode) return; + const display = displayByArtifact.get(artifact.id)!; + artifact.pendingFillOpacity = fillShareOf( + display, + parentFillOf(artifact.parentImage) + ); + if (display.outlineOpacity !== undefined) { + artifact.pendingOutlineOpacity = display.outlineOpacity; + } + if (display.visible !== undefined) { + artifact.pendingVisibility = display.visible; + } + }); + + // These consumed view configs would otherwise restore under an unmapped + // data id after the group is split. + const artifactIds = new Set(artifacts.map((artifact) => artifact.id)); + Object.values(manifest.viewByID ?? {}).forEach((view: any) => { + if (!view?.config) return; + artifactIds.forEach((artifactId) => { + const config = view.config[artifactId]; + if (!config) return; + delete config.layers; + delete config.segmentGroup; + if (Object.keys(config).length === 0) delete view.config[artifactId]; + }); + }); +}; + +// 6.4.0 -> 7.0.0 moves identity off segment groups and off the vector tools' +// label records and onto segment types, with one per-image mask per +// type. JSON only: no voxels are read here, so a group is marked for the loaded +// restore stage to divide into one bounded mask per segment, enumerating its +// voxel values first when it carried no descriptors. Every binding's extent is +// a placeholder that stage replaces. +const migrate640To700 = (inputManifest: any) => { + const manifest = JSON.parse(JSON.stringify(inputManifest)); + + // Insertion order is the migrated order: groups in manifest order, then the + // vector-tool labels in the order their tools reference them. + const recordsByParent = new Map(); + const segments: any[] = []; + + // Ids are built by joining legacy identifiers with '-', which those + // identifiers may themselves contain, so distinct sources can produce the + // same string. Restore keys a map on them, so a collision silently misroutes + // one record onto another. Disambiguate deterministically. + const usedIds = new Set(); + const uniqueId = (candidate: string) => { + let id = candidate; + for (let n = 2; usedIds.has(id); n += 1) id = `${candidate}-${n}`; + usedIds.add(id); + return id; + }; + + const addSegment = (into: any[], id: string, type: any) => { + into.push({ id, ...type }); + return id; + }; + + // One registry backs paint and the vector tools, so a name a segment group + // already carries is that same segment when a tool label repeats it, and the + // first to declare it sets the appearance. Groups themselves never merge: + // two of them on one image would put two masks on one segment, and the app + // keeps only one. + const segmentIdByName: Record = {}; + + const addRecord = (parentImage: string, record: any) => { + const records = recordsByParent.get(parentImage) ?? []; + records.push(record); + recordsByParent.set(parentImage, records); + }; + + const groups: any[] = Array.isArray(manifest.segmentGroups) + ? manifest.segmentGroups + : []; + + const paint = manifest.tools?.paint; + const activeGroupId = paint?.activeSegmentGroupID; + const activeValue = paint?.activeSegment; + if (paint) { + delete paint.activeSegmentGroupID; + delete paint.activeSegment; + } + // Captured as the type is emitted, because uniqueId may have suffixed the id + // that the legacy pair would have interpolated to. + let selectedSegmentId: string | undefined; + + const artifacts = groups.map((group) => { + const metadata = group.metadata ?? {}; + const descriptors = metadata.segments; + const parentImage = metadata.parentImage; + + if (!recordsByParent.has(parentImage)) { + recordsByParent.set(parentImage, []); + } + + descriptorValues(descriptors).forEach((value) => { + const mask = descriptors.byValue[String(value)]; + const segmentId = uniqueId(`${group.id}-${value}`); + addSegment(segments, segmentId, { + name: mask.name, + color: mask.color, + visible: mask.visible ?? true, + locked: mask.locked ?? false, + }); + segmentIdByName[mask.name] ??= segmentId; + if (group.id === activeGroupId && value === activeValue) { + selectedSegmentId = segmentId; + } + addRecord(parentImage, { + id: uniqueId(`record-${segmentId}`), + segmentId, + representations: { + labelmap: { + // The group still holds these voxels; restore splits them out. + artifactId: group.id, + sourceValue: value, + extent: emptyExtent(), + }, + }, + }); + }); + + return { + id: group.id, + parentImage, + name: metadata.name, + ...(group.path === undefined ? {} : { path: group.path }), + ...(group.dataSourceId === undefined + ? {} + : { dataSourceId: group.dataSourceId }), + ...(metadata.source ? { source: metadata.source } : {}), + ...(descriptors ? {} : { pendingDecode: true }), + // Its segments are decoded during restore, after the selection would have + // been applied, so the value to reselect travels with the artifact. + ...(!descriptors && + group.id === activeGroupId && + typeof activeValue === 'number' + ? { pendingActiveValue: activeValue } + : {}), + }; + }); + + // Every label becomes a segment, referenced or not: the picker offered it + // before and goes on offering it. + const toolSegmentIds = (key: string, into: any[]) => { + const entry = manifest.tools?.[key]; + if (!entry) return {} as Record; + + const labels = entry.labels ?? {}; + const segmentIdByLabel: Record = {}; + Object.entries(labels).forEach(([labelId, label]: [string, any]) => { + const { labelName, color, strokeWidth } = label; + const name = labelName || labelId; + segmentIdByLabel[labelId] = + segmentIdByName[name] ?? + addSegment(into, uniqueId(`${key}-${labelId}`), { + name, + color: cssColorToRGBA(color ?? ''), + ...(strokeWidth === undefined ? {} : { strokeWidth }), + }); + segmentIdByName[name] = segmentIdByLabel[labelId]; + }); + + entry.tools = (Array.isArray(entry.tools) ? entry.tools : []).map( + (tool: any) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { label, labelName, color, strokeWidth, ...rest } = tool; + const segmentId = segmentIdByLabel[label]; + return segmentId === undefined ? rest : { ...rest, segmentId }; + } + ); + + delete entry.labels; + return segmentIdByLabel; + }; + + ['rulers', 'rectangles', 'polygons'].forEach((key) => + toolSegmentIds(key, segments) + ); + + const segmentations = [...recordsByParent.entries()].map( + ([parentImage, records]) => ({ + id: `segmentation-${parentImage}`, + name: datasetDisplayName(manifest, parentImage), + parentImage, + masks: records, + order: records.map((record) => record.id), + }) + ); + + if (artifacts.length > 0) manifest.segmentationArtifacts = artifacts; + if (segmentations.length > 0) manifest.segmentations = segmentations; + if (segments.length > 0) manifest.segments = segments; + if (selectedSegmentId) manifest.selectedSegment = selectedSegmentId; + delete manifest.segmentGroups; + + migrateLegacyDisplay(manifest); + manifest.version = '7.0.0'; + return manifest; +}; + export const migrateManifest = (manifestString: string) => { const inputManifest = JSON.parse(manifestString); return pipe( @@ -171,6 +549,8 @@ export const migrateManifest = (manifestString: string) => { migrateOrPass(['5.0.1'], migrate501To600), migrateOrPass(['6.0.0'], migrate600To610), migrateOrPass(['6.1.0', '6.1.1'], migrate610To620), - migrateOrPass(['6.3.0'], migrate630To640) + migrateOrPass(['6.3.0'], migrate630To640), + // No 6.2.0 -> 6.3.0 step exists, so a 6.2 manifest arrives here directly. + migrateOrPass(['6.2.0', '6.4.0'], migrate640To700) ); }; diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index 9be42ba9c..16995e75b 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -20,7 +20,6 @@ import type { SliceConfig, WindowLevelConfig, LayersConfig, - SegmentGroupConfig, VolumeColorConfig, CinePlaybackViewConfig, } from '@/src/store/view-configs/types'; @@ -42,6 +41,7 @@ import { type LayoutDirection, type LayoutItem, } from '@/src/types/layout'; +import { DEFAULT_SEGMENTATION_FILL_OPACITY } from '@/src/segmentation/model'; const FileSource = z.object({ id: z.number(), @@ -266,11 +266,6 @@ const LayersConfig = z.object({ blendConfig: BlendConfig, }) satisfies z.ZodType; -const SegmentGroupConfig = z.object({ - outlineOpacity: z.number(), - outlineThickness: z.number(), -}) satisfies z.ZodType; - const CinePlaybackViewConfig = z.object({ frame: z.number(), }) satisfies z.ZodType; @@ -279,7 +274,6 @@ const ViewConfig = z.object({ window: WindowLevelConfig.optional(), slice: SliceConfig.optional(), layers: LayersConfig.optional(), - segmentGroup: SegmentGroupConfig.optional(), camera: CameraConfig.optional(), volumeColorConfig: VolumeColorConfig.optional(), cinePlayback: CinePlaybackViewConfig.optional(), @@ -300,14 +294,6 @@ export type View = z.infer; const RGBAColor = z.tuple([z.number(), z.number(), z.number(), z.number()]); -const SegmentMask = z.object({ - value: z.number(), - name: z.string(), - color: RGBAColor, - visible: z.boolean().default(true), - locked: z.boolean().optional(), -}); - // Provenance of a scene object produced by a processing job. This durable // identity prevents a restored result from being applied twice. Optional and // additive wherever it is used; hand-made state has none. The shape mirrors the @@ -318,31 +304,104 @@ export const ProcessingResultSource = z.object({ outputId: z.string(), }); -export const SegmentGroupMetadata = z.object({ +const Extent3D = z.tuple([ + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), +]); + +const LabelmapBinding = z.object({ + extent: Extent3D, + path: z.string(), + name: z.string().optional(), + source: ProcessingResultSource.optional(), +}); + +// Only incoming manifests can refer to an unsplit labelmap. Normal saved +// masks always own an archive entry. +const ImportedLabelmapBinding = LabelmapBinding.extend({ + path: z.string().optional(), + artifactId: z.string().optional(), + sourceValue: z.number().optional(), +}).refine( + (binding) => + (binding.path === undefined) !== (binding.artifactId === undefined), + { message: 'A labelmap binding names either a path or an artifact' } +); + +// Everything the user sees or sets lives on the type; a record is one image's +// mask for it. +const SegmentMask = z.object({ + id: z.string(), + segmentId: z.string(), + representations: z.object({ labelmap: LabelmapBinding.optional() }), +}); + +// Serialized as an ordered array, unused types included: the order is what the +// picker lists and the renderer offsets by, and restore re-mints ids in it. +export const Segment = z.object({ + id: z.string(), + name: z.string(), + color: RGBAColor, + visible: z.boolean().default(true), + locked: z.boolean().default(false), + fillOpacity: z.number().optional(), + outlineOpacity: z.number().optional(), + strokeWidth: z.number().optional(), +}); + +export type SegmentWire = z.infer; + +export const Segmentation = z.object({ + id: z.string(), name: z.string(), - // The explicit parent binding stays REQUIRED: a segment group entry without - // a parent must not exist at all (the backend composes a parentless - // labelmap as an ordinary image dataset, - // never as a segment group). Segment descriptors are OPTIONAL: when absent, - // restore enumerates the labelmap's non-background voxel values and applies - // the same default names/colors (and embedded .seg.nrrd metadata overlay) - // that live convertImageToLabelmap uses. parentImage: z.string(), - segments: z - .object({ - order: z.number().array(), - byValue: z.record(z.string(), SegmentMask), - }) - .optional(), - source: ProcessingResultSource.optional(), + masks: SegmentMask.array(), + order: z.string().array(), + fillOpacity: z.number().default(DEFAULT_SEGMENTATION_FILL_OPACITY), + outlineOpacity: z.number().default(1), + outlineThickness: z.number().default(2), }); -export const SegmentGroup = z +const ImportedSegmentation = Segmentation.extend({ + masks: SegmentMask.extend({ + representations: z.object({ + labelmap: ImportedLabelmapBinding.optional(), + }), + }).array(), +}); + +export type Segmentation = z.infer; + +/** + * A whole-volume labelmap that no mask holds yet: restore divides it into one + * bounded mask per segment. Two producers write these and no save does, since + * a mask that exists carries its own voxels. A 6.4.0 migration emits one per + * legacy segment group, and a backend composes one to hand VolView a labelmap + * to attach, wired to its bytes through `dataSourceId`. + */ +export const SegmentationArtifact = z .object({ id: z.string(), + parentImage: z.string(), + name: z.string(), path: z.string().optional(), dataSourceId: z.number().optional(), - metadata: SegmentGroupMetadata, + source: ProcessingResultSource.optional(), + // No mask names this one, so nothing says what its values mean: the + // restore enumerates its voxels to find out. + pendingDecode: z.boolean().optional(), + // Migration-only: the legacy active paint value, reactivated once the + // decode above has created the segments it names. + pendingActiveValue: z.number().optional(), + // Also migration-only: display state applied after an artifact has been + // decoded into segments, which is where it lands once they exist. + pendingFillOpacity: z.number().optional(), + pendingOutlineOpacity: z.number().optional(), + pendingVisibility: z.boolean().optional(), }) .refine( (data) => data.path !== undefined || data.dataSourceId !== undefined, @@ -351,7 +410,7 @@ export const SegmentGroup = z } ); -export type SegmentGroup = z.infer; +export type SegmentationArtifact = z.infer; const LPSAxis = z.union([ z.literal('Axial'), @@ -371,10 +430,7 @@ const annotationTool = z.object({ frame: z.number().optional(), id: z.string().optional() as unknown as z.ZodType, name: z.string().optional(), - color: z.string().optional(), - strokeWidth: z.number().optional(), - label: z.string().optional(), - labelName: z.string().optional(), + segmentId: z.string().optional(), metadata: z.record(z.string(), z.string()).optional(), // Job provenance, present only on a tool applied from a result. Unknown keys // are stripped on parse, so restore would silently drop the idempotency key @@ -382,10 +438,11 @@ const annotationTool = z.object({ source: ProcessingResultSource.optional(), }); +// Every shape names a type in its registry; the registries themselves are +// manifest roots, so a tool entry carries geometry only. const makeToolEntry = (tool: z.ZodObject) => z.object({ tools: z.array(tool), - labels: z.record(z.string(), tool.partial()), }); const Ruler = annotationTool.extend({ @@ -410,8 +467,6 @@ const Polygons = makeToolEntry(Polygon); const ToolsEnumNative = z.nativeEnum(ToolsEnum); const Paint = z.object({ - activeSegmentGroupID: z.string().nullable().optional(), - activeSegment: z.number().nullish(), brushSize: z.number().optional(), crossPlaneSync: z.boolean().optional(), labelmapOpacity: z.number().optional(), @@ -450,7 +505,10 @@ export const ManifestSchema = z.object({ datasets: Dataset.array().optional(), dataSources: DataSource.array(), datasetFilePath: z.record(z.string(), z.string()).optional(), - segmentGroups: SegmentGroup.array().optional(), + segmentations: ImportedSegmentation.array().optional(), + segmentationArtifacts: SegmentationArtifact.array().optional(), + segments: Segment.array().optional(), + selectedSegment: z.string().optional(), tools: Tools.optional(), activeView: z.string().optional().nullable(), isActiveViewMaximized: z.boolean().optional(), diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts index 11f981acb..8e302613e 100644 --- a/src/io/state-file/serialize.ts +++ b/src/io/state-file/serialize.ts @@ -1,6 +1,7 @@ import JSZip from 'jszip'; 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 { useLayersStore } from '@/src/store/datasets-layers'; import { useToolStore } from '@/src/store/tools'; import { Tools } from '@/src/store/tools/types'; @@ -9,7 +10,7 @@ import { Manifest, ManifestSchema, ParentToLayers, - SegmentGroup, + Segmentation, StateFile, } from '@/src/io/state-file/schema'; @@ -42,7 +43,7 @@ declareManifestRefs('primarySelection', (manifest) => ); export const MANIFEST = 'manifest.json'; -export const MANIFEST_VERSION = '6.4.0'; +export const MANIFEST_VERSION = '7.0.0'; type ManifestCandidate = Record; @@ -120,59 +121,43 @@ function validateCoreGraph(core: Manifest, zip: JSZip) { return { sourceIds, datasetIds }; } -// Last gate before a session archive is written. Three responsibilities: -// -// 1. Abort on an incoherent core graph (`validateCoreGraph`) — a bad version, -// duplicate/dangling/cyclic data-source refs, missing local files, or a -// dataset pointing at no source. Corruption here yields an UNrestorable -// archive, so it throws rather than omits. -// 2. Prune segment groups (and their orphaned archive members from the zip) -// and layer relationships that reference a missing dataset/source. -// 3. Drop any optional root whose SHAPE fails the schema, gracefully. -// -// Referential integrity of the OTHER optional state (view `dataID`s, annotation -// `imageID`s, crop keys, the active paint group, primary/active selections) is -// owned by the synchronous remove cascade — see `datasetStore.remove` and -// `datasetRemoveCascade.spec.ts`. Those ids are kept live-clean at the source, -// and a stale one is harmless on restore anyway (deserialize remaps every id -// through its id-map and ignores misses), so this function does not re-walk -// them. Segment groups stay here because an orphaned one leaves dead `.seg.nrrd` -// bytes in the archive, which is a real cost the cascade does not address. +// Validate the source graph and saved mask files before writing an archive. +// Optional invalid content is reported and omitted; an invalid source graph +// aborts the save. Dataset and tool references are kept clean by their stores' +// removal cascades and checked below in development builds. export function normalizeManifest(manifest: Manifest, zip: JSZip) { const candidate = manifest as unknown as ManifestCandidate; const core = coreManifestSchema.parse(candidate) as Manifest; - const { sourceIds, datasetIds } = validateCoreGraph(core, zip); + const { datasetIds } = validateCoreGraph(core, zip); const omitted: string[] = []; - const rawGroups = Array.isArray(candidate.segmentGroups) - ? candidate.segmentGroups + const rawName = (raw: unknown, fallback: string) => + isRecord(raw) && typeof raw.name === 'string' ? raw.name : fallback; + + const rawSegmentations = Array.isArray(candidate.segmentations) + ? candidate.segmentations : []; - const validGroups = rawGroups.flatMap((raw, index) => { - const parsed = SegmentGroup.safeParse(raw); - const name = - isRecord(raw) && - isRecord(raw.metadata) && - typeof raw.metadata.name === 'string' - ? raw.metadata.name - : `segmentGroups[${index}]`; + const validSegmentations = rawSegmentations.flatMap((raw, index) => { + const parsed = Segmentation.safeParse(raw); + const name = rawName(raw, `segmentations[${index}]`); let reason: string | null = null; - if (!parsed.success) reason = 'invalid segment-group record'; - else if (!datasetIds.has(parsed.data.metadata.parentImage)) { - reason = `parent dataset ${parsed.data.metadata.parentImage} is missing`; - } else if ( - parsed.data.dataSourceId !== undefined && - !sourceIds.has(parsed.data.dataSourceId) - ) { - reason = `artifact data source ${parsed.data.dataSourceId} is missing`; - } else if (parsed.data.path && zip.file(parsed.data.path) === null) { - reason = `archive member ${parsed.data.path} is missing`; + if (!parsed.success) reason = 'invalid segmentation record'; + else if (!datasetIds.has(parsed.data.parentImage)) { + reason = `parent dataset ${parsed.data.parentImage} is missing`; } if (!parsed.success || reason) { omitted.push(`${name}: ${reason}`); - if (isRecord(raw) && typeof raw.path === 'string') zip.remove(raw.path); return []; } - return [parsed.data]; + + const masks = parsed.data.masks.map((mask) => { + const binding = mask.representations.labelmap; + if (!binding || zip.file(binding.path) !== null) return mask; + const unreachableReason = `archive member ${binding.path} is missing`; + omitted.push(`${name}.masks[${mask.id}]: ${unreachableReason}`); + return { ...mask, representations: {} }; + }); + return [{ ...parsed.data, masks }]; }); let validLayers: ParentToLayers | undefined; @@ -203,14 +188,20 @@ export function normalizeManifest(manifest: Manifest, zip: JSZip) { if (process.env.NODE_ENV !== 'production') { const resolvable: Record> = { dataset: datasetIds, - segmentGroup: new Set(validGroups.map((group) => group.id)), + segment: new Set( + Array.isArray(candidate.segments) + ? candidate.segments.flatMap((raw) => + isRecord(raw) && typeof raw.id === 'string' ? [raw.id] : [] + ) + : [] + ), view: new Set( isRecord(candidate.viewByID) ? Object.keys(candidate.viewByID) : [] ), }; const kindLabel: Record = { dataset: 'dataset', - segmentGroup: 'segment group', + segment: 'segment type', view: 'view', }; const dangling = collectManifestRefs(candidate) @@ -249,6 +240,8 @@ export function normalizeManifest(manifest: Manifest, zip: JSZip) { // deep-copied) twice. const optionalRoots = [ 'tools', + 'segments', + 'selectedSegment', 'activeView', 'isActiveViewMaximized', 'viewByID', @@ -269,7 +262,7 @@ export function normalizeManifest(manifest: Manifest, zip: JSZip) { const normalized = { ...core, - segmentGroups: validGroups, + segmentations: validSegmentations, ...(validLayers ? { parentToLayers: validLayers } : {}), ...Object.fromEntries(optionalEntries), } as Manifest; @@ -290,7 +283,8 @@ const serializingStoreHooks = [ useDatasetStore, useViewStore, useViewConfigStore, - useSegmentGroupStore, + useSegmentStore, + useSegmentationStore, useToolStore, useLayersStore, ]; @@ -314,11 +308,9 @@ export async function serialize( datasets: [], dataSources: [], datasetFilePath: {}, - segmentGroups: [], + segmentations: [], tools: { paint: { - activeSegmentGroupID: null, - activeSegment: null, brushSize: 8, crossPlaneSync: false, }, diff --git a/src/processing/__tests__/applyResults.annotations.spec.ts b/src/processing/__tests__/applyResults.annotations.spec.ts index 52133f9e1..2e6bdc370 100644 --- a/src/processing/__tests__/applyResults.annotations.spec.ts +++ b/src/processing/__tests__/applyResults.annotations.spec.ts @@ -14,9 +14,20 @@ import type { } from '@/src/processing/types'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDICOMStore } from '@/src/store/datasets-dicom'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { cssColorToRGBA } from '@/src/segmentation/color'; +import { useMessageStore } from '@/src/store/messages'; +import { TOOL_COLORS } from '@/src/config'; +import { + mintSegment, + lockSegment, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; import { useRulerStore } from '@/src/store/tools/rulers'; import { useRectangleStore } from '@/src/store/tools/rectangles'; import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useViewStore } from '@/src/store/views'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; // --------------------------------------------------------------------------- // Applying an `add-annotations` result. @@ -305,59 +316,189 @@ describe('applyIntent — add-annotations', () => { expect(toolCounts()).toEqual({ rulers: 0, rectangles: 0, polygons: 0 }); }); - it('keeps a label name that repeats across kinds independent per store', async () => { + it('gives one segment to a name that repeats across kinds', async () => { await apply(intent(), context(IMAGE_ID)); - const ruler = onlyTool(useRulerStore()); - const rectangle = onlyTool(useRectangleStore()); - expect(ruler.labelName).toBe('roi'); - expect(rectangle.labelName).toBe('roi'); - expect(ruler.label).not.toBe(rectangle.label); - - // addTool re-reads the style from the merged label, so these ARE the - // namespaced styles that landed. - expect(ruler.color).toBe('#ff0000'); - expect(ruler.strokeWidth).toBe(3); - expect(rectangle.color).toBe('#00ff00'); - expect(rectangle.fillColor).toBe('#00ff0033'); - expect(onlyTool(usePolygonStore()).color).toBe('#0000ff'); + const rulers = useRulerStore(); + const rectangles = useRectangleStore(); + const polygons = usePolygonStore(); + const ruler = onlyTool(rulers); + const rectangle = onlyTool(rectangles); + + // One registry: the name is the segment, whichever tool drew the shape. + expect(ruler.segmentId).toBe(rectangle.segmentId); + expect(rulers.appearanceOfTool(ruler.id).name).toBe('roi'); + // A different name is still a different segment. + expect(onlyTool(polygons).segmentId).not.toBe(ruler.segmentId); + + // Every kind declared a style for the name; the first to bind it wins. + expect(rectangles.appearanceOfTool(rectangle.id).cssColor).toBe('#ff0000'); + expect(rectangles.appearanceOfTool(rectangle.id).strokeWidth).toBe(3); }); - it('merges into an existing label of the same name instead of duplicating it', async () => { + it('binds an existing segment of the same name instead of minting one', async () => { const rulerStore = useRulerStore(); - // 'Label 1' ships as the stores' default label. - const [existingId] = Object.keys(rulerStore.labels); - const before = Object.keys(rulerStore.labels).length; + const registry = useSegmentStore().segments; + const existingId = registry.addSegment({ + name: 'Measured', + color: cssColorToRGBA('#ff0000'), + }); + const before = registry.segmentList.value.length; + + const file = annotationsFile(); + file.labels.rulers = { Measured: { color: '#123456', strokeWidth: 3 } }; + file.tools.rulers[0].labelName = 'Measured'; + file.tools.rectangles = []; + file.tools.polygons = []; + serveFile(file); + + expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); + expect(registry.segmentList.value).toHaveLength(before); + // The registry's own appearance wins on a name match. + expect(registry.appearanceOf(existingId).cssColor).toBe('#ff0000'); + expect(onlyTool(rulerStore).segmentId).toBe(existingId); + }); + + it('keeps a segment’s colour when a label states one the parser rejects', async () => { + const registry = useSegmentStore().segments; + const existingId = registry.addSegment({ + name: 'Measured', + color: cssColorToRGBA('#ff0000'), + }); const file = annotationsFile(); - file.labels.rulers = { 'Label 1': { color: '#123456', strokeWidth: 3 } }; - file.tools.rulers[0].labelName = 'Label 1'; + // Functional CSS colours are not part of the accepted syntax. + file.labels.rulers = { + Measured: { color: 'rgb(0, 255, 0)' }, + Fresh: { color: 'rgb(0, 255, 0)' }, + }; + file.tools.rulers = [ + { ...file.tools.rulers[0], labelName: 'Measured' }, + { ...file.tools.rulers[0], labelName: 'Fresh' }, + ]; file.tools.rectangles = []; file.tools.polygons = []; serveFile(file); expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); - expect(Object.keys(rulerStore.labels)).toHaveLength(before); - expect(rulerStore.labels[existingId].color).toBe('#123456'); - expect(onlyTool(rulerStore).label).toBe(existingId); + + // The bound segment keeps what it had, and a minted one keeps its + // automatic palette colour — neither turns opaque black. + expect(registry.appearanceOf(existingId).cssColor).toBe('#ff0000'); + const minted = registry.segmentList.value.find( + (segment) => segment.name === 'Fresh' + )!; + expect(TOOL_COLORS).toContain(registry.appearanceOf(minted.id).cssColor); + + const titles = useMessageStore().messages.map((message) => message.title); + expect(titles).toHaveLength(1); + expect(titles[0]).toContain('Measured (rgb(0, 255, 0))'); + expect(titles[0]).toContain('Fresh (rgb(0, 255, 0))'); }); - it('leaves the label picker where the user left it', async () => { + it('applies a hex or CSS keyword label colour', async () => { + const file = annotationsFile(); + file.labels.rulers = { Hexed: { color: '#00ff00' } }; + file.tools.rulers[0].labelName = 'Hexed'; + file.labels.polygons = { Named: { color: 'lime' } }; + file.tools.polygons[0].labelName = 'Named'; + file.tools.rectangles = []; + serveFile(file); + + expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); + + const rulers = useRulerStore(); + const polygons = usePolygonStore(); + expect(rulers.appearanceOfTool(onlyTool(rulers).id).cssColor).toBe( + '#00ff00' + ); + expect(polygons.appearanceOfTool(onlyTool(polygons).id).cssColor).toBe( + '#00ff00' + ); + expect(useMessageStore().messages).toHaveLength(0); + }); + + it('binds a locked segment’s type without touching its mask', async () => { + const segmentationStore = useSegmentationStore(); + const segmentation = segmentationStore.ensureSegmentationForImage(IMAGE_ID); + const lockedSegment = mintSegment({ + name: 'roi', + color: [17, 34, 51, 255], + }); + const locked = segmentationStore.createMask(segmentation.id, lockedSegment); + const voxels = segmentationStore.maskVoxels(locked.id); + voxels.materialize(); + const labelValue = SEGMENT_VALUE; + voxels.ensureContains([2, 2, 3, 3, 4, 4]); + voxels.scalars()[0] = labelValue; + voxels.image().modified(); + lockSegment(locked.id, true); + const maskBefore = voxels.image(); + const bindingBefore = { + ...voxels.binding()!, + extent: [...voxels.binding()!.extent], + }; + const scalarsBefore = Array.from(voxels.snapshot()); + + const file = annotationsFile(); + file.tools.rulers = []; + file.tools.polygons = []; + serveFile(file); + + expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); + expect(segmentation.order).toEqual([locked.id]); + expect(segmentationStore.getMask(locked.id)).toMatchObject({ + segmentId: lockedSegment, + representations: { labelmap: bindingBefore }, + }); + expect(useSegmentStore().segments.appearanceOf(lockedSegment).locked).toBe( + true + ); + expect(voxels.image()).toBe(maskBefore); + expect(Array.from(voxels.snapshot())).toEqual(scalarsBefore); + // The shape names the type, not this image's mask for it. + expect(onlyTool(useRectangleStore()).segmentId).toBe(lockedSegment); + }); + + it('leaves the picker where the user left it', async () => { const rulerStore = useRulerStore(); - const activeBefore = rulerStore.activeLabel; - expect(activeBefore).toBeTruthy(); + const registry = useSegmentStore().segments; + const selectedBefore = registry.addSegment({ name: 'Chosen' }); + expect(registry.selectedSegmentId.value).toBe(selectedBefore); const file = annotationsFile(); - // A name no store label carries, so merging must ADD one — the case that - // could steal the active selection. + // A name no type carries, so binding must MINT one, the case that could + // steal the selection. file.labels.rulers = { fresh: { color: '#abcdef' } }; file.tools.rulers[0].labelName = 'fresh'; serveFile(file); expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); - expect(rulerStore.activeLabel).toBe(activeBefore); - // The label still landed; only the picker was left alone. - expect(onlyTool(rulerStore).labelName).toBe('fresh'); + expect(registry.selectedSegmentId.value).toBe(selectedBefore); + // The type still landed; only the picker was left alone. + const ruler = onlyTool(rulerStore); + expect(rulerStore.appearanceOfTool(ruler.id).name).toBe('fresh'); + }); + + it('preserves the user’s selected type while result segments land', async () => { + seatImage('origin-image'); + seatImage('next-image'); + const segmentationStore = useSegmentationStore(); + const originSegmentation = + segmentationStore.ensureSegmentationForImage('origin-image'); + const selectedSegment = mintSegment({ name: 'User selection' }); + segmentationStore.createMask(originSegmentation.id, selectedSegment); + useSegmentStore().segments.selectSegment(selectedSegment); + useViewStore().setDataForAllViews(IMAGE_ID); + await nextTick(); + + expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); + expect(useSegmentStore().segments.selectedSegmentId.value).toBe( + selectedSegment + ); + + const next = segmentationStore.resolveEditTarget('next-image'); + expect(segmentationStore.getMask(next).segmentId).toBe(selectedSegment); }); it('leaves an unlabeled tool unlabeled', async () => { @@ -376,8 +517,8 @@ describe('applyIntent — add-annotations', () => { expect((await apply(intent(), context(IMAGE_ID))).status).toBe('applied'); const ruler = onlyTool(useRulerStore()); - expect(ruler.label).toBe(''); - expect(ruler.labelName).toBe(''); + expect(ruler.segmentId).toBe(''); + expect(useRulerStore().appearanceOfTool(ruler.id).name).toBe(''); }); it('is a no-op when a tool already carries the same source', async () => { @@ -430,7 +571,9 @@ describe('applyIntent — add-annotations', () => { it('rejects the whole result when any frame is not axis-aligned, before mutating', async () => { const rulerStore = useRulerStore(); - const labelsBefore = { ...rulerStore.labels }; + const typesBefore = rulerStore.segments.segmentList.value.map((type) => ({ + ...type, + })); const file = annotationsFile(); // Oblique: unrenderable, and no `slice` echo can rescue it. @@ -446,9 +589,9 @@ describe('applyIntent — add-annotations', () => { 'not aligned' ); // All-or-nothing: not even the rulers that WOULD have placed, and not the - // labels — merging restyles, so it is a mutation too. + // registry, since binding a name mints or restyles a type. expect(toolCounts()).toEqual({ rulers: 0, rectangles: 0, polygons: 0 }); - expect(rulerStore.labels).toEqual(labelsBefore); + expect(rulerStore.segments.segmentList.value).toEqual(typesBefore); }); it('places a plane past the image bounds, as the renderer already does', async () => { diff --git a/src/processing/__tests__/applyResults.segments.spec.ts b/src/processing/__tests__/applyResults.segments.spec.ts new file mode 100644 index 000000000..5c8cacf91 --- /dev/null +++ b/src/processing/__tests__/applyResults.segments.spec.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; + +import type { SegmentDescriptor } from '@/backend-contract'; +import { + applyIntent, + appApplyDependencies, +} from '@/src/processing/applyResults'; +import { buildSegNrrdMetadata } from '@/src/io/segNrrdMetadata'; +import { isEmptyExtent } from '@/src/segmentation/geometry'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { + seatImage, + seedVoxel, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +const registry = () => useSegmentStore().segments; +const store = () => useSegmentationStore(); +const source = { providerId: 'provider', jobId: 'job', outputId: 'mask' }; +const blue = [0, 0, 255, 255] as [number, number, number, number]; +const red = [255, 0, 0, 255] as [number, number, number, number]; + +const existingMask = (imageId: string, name: string) => { + const segmentId = registry().mintSegment({ name, color: red }); + const maskId = store().resolveEditTarget(imageId, segmentId); + seedVoxel(maskId, [1, 1, 1]); + return { segmentId, maskId }; +}; + +const importResult = (segments?: SegmentDescriptor[]) => + applyIntent( + { + intent: 'import-segmentation', + id: 'result', + name: 'output.nrrd', + url: 'https://example/output.nrrd', + segments, + source, + }, + { + jobId: 'job', + taskId: 'task', + providerId: 'provider', + submittedAt: '', + activeDatasetId: 'parent-B', + }, + { + ...appApplyDependencies(), + importVolume: async () => 'output', + removeDataset: (id) => useImageCacheStore().removeImage(id), + } + ); + +const appearanceOnB = () => + store() + .imageMasks('parent-B') + .map(({ segmentId }) => registry().appearanceOf(segmentId)); + +beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('parent-A'); + await seatImage('parent-B'); + const values = new Uint8Array(64); + values[21] = 1; + await seatImage('output', { name: 'output.nrrd', values }); +}); + +describe('processing segment identity', () => { + it("does not change another image's type named after the output file", async () => { + const { segmentId, maskId } = existingMask('parent-A', 'output'); + const toolId = usePolygonStore().addTool({ + imageID: 'parent-A', + segmentId, + }); + const before = registry().appearanceOf(segmentId); + + expect( + await importResult([{ value: 1, name: 'Liver', color: blue }]) + ).toEqual({ status: 'applied' }); + + expect(registry().appearanceOf(segmentId)).toEqual(before); + expect(store().getMask(maskId).segmentId).toBe(segmentId); + expect(usePolygonStore().toolByID[toolId].segmentId).toBe(segmentId); + expect(appearanceOnB()).toMatchObject([{ name: 'Liver', color: blue }]); + const mask = store().imageMasks('parent-B')[0]; + expect(mask.representations.labelmap?.extent).toEqual([1, 1, 1, 1, 1, 1]); + expect(mask.representations.labelmap?.source).toEqual(source); + }); + + it('reuses the declared type across images and keeps its existing appearance', async () => { + const { segmentId } = existingMask('parent-A', 'Tumor'); + registry().updateSegment(segmentId, { visible: false, locked: true }); + + await importResult([ + { value: 1, name: 'Tumor', color: blue, visible: true }, + ]); + + expect(store().imageMasks('parent-B')[0].segmentId).toBe(segmentId); + expect(appearanceOnB()).toMatchObject([ + { name: 'Tumor', color: red, visible: false, locked: true }, + ]); + expect(registry().segmentList.value).toHaveLength(1); + }); + + it('uses a distinct type when the declared type already has a mask on the target', async () => { + const { segmentId, maskId } = existingMask('parent-B', 'Tumor'); + + await importResult([{ value: 1, name: 'Tumor', color: blue }]); + + expect(store().getMask(maskId).segmentId).toBe(segmentId); + expect(appearanceOnB()).toMatchObject([ + { name: 'Tumor', color: red }, + { name: 'Tumor (2)', color: blue }, + ]); + }); + + it('overrides embedded names, keeps undescribed source values, and keeps a declared empty', async () => { + existingMask('parent-A', 'Embedded'); + const output = useImageCacheStore().imageById.output; + output.headerMetadata = buildSegNrrdMetadata( + [{ value: 1, name: 'Embedded', color: red, visible: true }], + [4, 4, 4] + ); + const scalars = output.getVtkImageData().getPointData().getScalars(); + scalars.getData()[42] = 7; + scalars.modified(); + + // Asserted, not discarded: the import pairs the masks it minted with the + // descriptors it decoded by position, so a descriptor list that grows + // after the decode fails the whole apply rather than the row below. + expect( + await importResult([ + { value: 1, name: 'Explicit', color: blue, visible: false }, + { value: 99, name: 'Absent', color: blue }, + ]) + ).toEqual({ status: 'applied' }); + + // Value 99 has no voxels and no header block. It used to be dropped; the + // maintainer's decision is that a segment a result DECLARES but leaves + // EMPTY appears as an empty row, so it is minted after the decoded ones. + expect(appearanceOnB()).toMatchObject([ + { name: 'Explicit', color: blue, visible: false }, + { name: 'output 7' }, + { name: 'Absent', color: blue, visible: true }, + ]); + expect(registry().findSegmentByName('Embedded')?.color).toEqual(red); + expect( + store().imageMasks('parent-B')[1].representations.labelmap?.extent + ).toEqual([2, 2, 2, 2, 2, 2]); + expect( + isEmptyExtent( + store().imageMasks('parent-B')[2].representations.labelmap!.extent + ) + ).toBe(true); + }); + + it('applies source-value descriptions independently to every component', async () => { + const values = new Uint8Array(128); + values[21 * 2] = 1; + values[42 * 2 + 1] = 1; + useImageCacheStore() + .getVtkImageData('output')! + .getPointData() + .setScalars(vtkDataArray.newInstance({ numberOfComponents: 2, values })); + + await importResult([{ value: 1, name: 'Liver', color: blue }]); + + expect(appearanceOnB()).toMatchObject([ + { name: 'Liver', color: blue }, + { name: 'Liver (2)', color: blue }, + ]); + expect( + store() + .imageMasks('parent-B') + .map((mask) => mask.representations.labelmap?.extent) + ).toEqual([ + [1, 1, 1, 1, 1, 1], + [2, 2, 2, 2, 2, 2], + ]); + }); +}); diff --git a/src/processing/__tests__/applyResults.spec.ts b/src/processing/__tests__/applyResults.spec.ts index d0582e390..abab82494 100644 --- a/src/processing/__tests__/applyResults.spec.ts +++ b/src/processing/__tests__/applyResults.spec.ts @@ -19,16 +19,28 @@ import { useMessageStore } from '@/src/store/messages'; // store is the real one. // --------------------------------------------------------------------------- +/** + * What a conversion reports back: the segment each SOURCE label value became. + * Colliding values are remapped as the import lands, so the source value is the + * only handle a descriptor can match on. + */ +const importedComponent = (bySourceValue: Record) => + Object.entries(bySourceValue).map(([sourceValue, maskId]) => ({ + sourceValue: Number(sourceValue), + maskId, + })); + const recordingDependencies = () => ({ fetchResult: vi.fn(), openVolumeUrls: vi.fn(async () => ['dataset-live']), importVolume: vi.fn(async (): Promise => 'child-selection'), removeDataset: vi.fn(), addLayer: vi.fn(async (): Promise => 'layer-1'), - segmentGroups: { + segmentWriter: { resultSourcesInScene: vi.fn((): Array => []), - convertImageToLabelmap: vi.fn(async () => ['seg-group']), - updateSegment: vi.fn(), + convertImageToLabelmap: vi.fn(async () => [ + importedComponent({ 1: 'segment-1', 2: 'segment-2' }), + ]), }, }); @@ -88,7 +100,7 @@ describe('applyIntent', () => { names: [file.name], }); expect(deps.addLayer).not.toHaveBeenCalled(); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); }); it('add-layer attaches a layer onto the originating dataset', async () => { @@ -113,60 +125,42 @@ describe('applyIntent', () => { }); }); - it('add-segment-group converts the labelmap and applies descriptors to the created group', async () => { - deps.segmentGroups.convertImageToLabelmap.mockResolvedValue(['group-1']); + it('passes explicit descriptors into labelmap conversion', async () => { const segments = [ { value: 1, name: 'liver', color: rgba(255, 0, 0, 255) }, { value: 2, name: 'tumor', color: rgba(0, 255, 0, 255), visible: false }, ]; await apply( - { intent: 'add-segment-group', ...file, segments }, + { intent: 'import-segmentation', ...file, segments }, context('parent') ); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - undefined - ); - expect(deps.segmentGroups.updateSegment).toHaveBeenCalledTimes(2); - expect(deps.segmentGroups.updateSegment).toHaveBeenCalledWith( - 'group-1', - 1, - { - name: 'liver', - color: [255, 0, 0, 255], - } - ); - expect(deps.segmentGroups.updateSegment).toHaveBeenCalledWith( - 'group-1', - 2, - { - name: 'tumor', - color: [0, 255, 0, 255], - visible: false, - } + undefined, + segments ); expect(deps.openVolumeUrls).not.toHaveBeenCalled(); }); - it('add-segment-group removes the temporarily imported child dataset', async () => { + it('import-segmentation removes the temporarily imported child dataset', async () => { const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('applied'); expect(deps.removeDataset).toHaveBeenCalledWith('child-selection'); expect(deps.removeDataset.mock.invocationCallOrder[0]).toBeGreaterThan( - deps.segmentGroups.convertImageToLabelmap.mock.invocationCallOrder[0] + deps.segmentWriter.convertImageToLabelmap.mock.invocationCallOrder[0] ); }); - it('add-segment-group removes the imported child even when conversion fails', async () => { - deps.segmentGroups.convertImageToLabelmap.mockRejectedValue( + it('import-segmentation removes the imported child even when conversion fails', async () => { + deps.segmentWriter.convertImageToLabelmap.mockRejectedValue( new Error('bounds do not intersect') ); const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('failed'); @@ -182,14 +176,14 @@ describe('applyIntent', () => { expect(deps.removeDataset).not.toHaveBeenCalled(); }); - it('add-segment-group with no segments still converts (embedded metadata)', async () => { - await apply({ intent: 'add-segment-group', ...file }, context('parent')); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + it('import-segmentation with no segments still converts (embedded metadata)', async () => { + await apply({ intent: 'import-segmentation', ...file }, context('parent')); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', + undefined, undefined ); - expect(deps.segmentGroups.updateSegment).not.toHaveBeenCalled(); }); it('stamps structured provider-qualified provenance on the created group', async () => { @@ -199,13 +193,14 @@ describe('applyIntent', () => { outputId: 'outputLabelmap', }; await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - source + source, + undefined ); }); @@ -215,93 +210,79 @@ describe('applyIntent', () => { jobId: 'job-abc123', outputId: 'outputLabelmap', }; - deps.segmentGroups.resultSourcesInScene.mockReturnValue([source]); + deps.segmentWriter.resultSourcesInScene.mockReturnValue([source]); const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); expect(outcome.status).toBe('applied'); expect(deps.importVolume).not.toHaveBeenCalled(); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(deps.openVolumeUrls).not.toHaveBeenCalled(); }); - it('applies a different output from the same restored job', async () => { - deps.segmentGroups.resultSourcesInScene.mockReturnValue([ - { providerId: 'p1', jobId: 'job-abc123', outputId: 'existing-output' }, - ]); - const source = { - providerId: 'p1', - jobId: 'job-abc123', - outputId: 'new-output', - }; + type Source = { providerId: string; jobId: string; outputId: string }; + + /** Applies `source` against the provenance the scene already holds. */ + const expectAppliedBeside = async (inScene: Source, source: Source) => { + deps.segmentWriter.resultSourcesInScene.mockReturnValue([inScene]); const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); expect(outcome.status).toBe('applied'); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - source + source, + undefined ); - }); + }; - it('applies matching raw job and output ids from a different provider', async () => { - deps.segmentGroups.resultSourcesInScene.mockReturnValue([ - { providerId: 'provider-a', jobId: '1', outputId: 'seg' }, - ]); - const source = { - providerId: 'provider-b', - jobId: '1', - outputId: 'seg', - }; - - const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, - context('parent') - ); + it('applies a different output from the same restored job', () => + expectAppliedBeside( + { providerId: 'p1', jobId: 'job-abc123', outputId: 'existing-output' }, + { providerId: 'p1', jobId: 'job-abc123', outputId: 'new-output' } + )); - expect(outcome.status).toBe('applied'); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( - 'child-selection', - 'parent', - source - ); - }); + it('applies matching raw job and output ids from a different provider', () => + expectAppliedBeside( + { providerId: 'provider-a', jobId: '1', outputId: 'seg' }, + { providerId: 'provider-b', jobId: '1', outputId: 'seg' } + )); it('does not infer an application receipt when provenance is absent', async () => { - deps.segmentGroups.resultSourcesInScene.mockReturnValue([undefined]); + deps.segmentWriter.resultSourcesInScene.mockReturnValue([undefined]); const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('applied'); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledTimes(1); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); }); - it('add-segment-group with no originating dataset falls back to opening', async () => { - await apply({ intent: 'add-segment-group', ...file }, context(undefined)); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + it('import-segmentation with no originating dataset falls back to opening', async () => { + await apply({ intent: 'import-segmentation', ...file }, context(undefined)); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ urls: [file.url], names: [file.name], }); }); - it('add-segment-group reports an explicit failure when the result fails to load (#7)', async () => { + it('import-segmentation reports an explicit failure when the result fails to load (#7)', async () => { deps.importVolume.mockResolvedValue(null); const applied = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(applied.status).toBe('failed'); expect(errorMessages()).toEqual([]); }); @@ -337,55 +318,33 @@ describe('applyIntent', () => { expect(deps.removeDataset).toHaveBeenCalledWith('child-selection'); expect(errorMessages()).toEqual([]); }); - - it('is additive-only: writes into the NEW group, never a pre-existing one', async () => { - deps.segmentGroups.resultSourcesInScene.mockReturnValue([undefined]); - deps.segmentGroups.convertImageToLabelmap.mockResolvedValue(['new-group']); - await apply( - { - intent: 'add-segment-group', - ...file, - segments: [{ value: 1, name: 'liver', color: rgba(1, 2, 3, 4) }], - }, - context('parent') - ); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledTimes(1); - expect(deps.segmentGroups.updateSegment).toHaveBeenCalledWith( - 'new-group', - 1, - expect.anything() - ); - expect(deps.segmentGroups.updateSegment).not.toHaveBeenCalledWith( - 'existing-group', - expect.anything(), - expect.anything() - ); - }); }); describe('autoLoadProcessingResults', () => { it('routes every supported intent through the shared applier', async () => { - deps.segmentGroups.convertImageToLabelmap.mockResolvedValue(['seg-group']); + deps.segmentWriter.convertImageToLabelmap.mockResolvedValue([ + importedComponent({ 1: 'segment-1' }), + ]); await autoLoad( [ result({ id: 'a', intent: 'add-base-image' }), result({ id: 'b', intent: 'add-layer' }), result({ id: 'c', - intent: 'add-segment-group', + intent: 'import-segmentation', source: { providerId: 'p1', jobId: 'j1', outputId: 'seg' }, segments: [{ value: 1, name: 'liver', color: rgba(1, 2, 3, 4) }], }), ], context('parent') ); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledTimes(1); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - { providerId: 'p1', jobId: 'j1', outputId: 'seg' } + { providerId: 'p1', jobId: 'j1', outputId: 'seg' }, + [{ value: 1, name: 'liver', color: rgba(1, 2, 3, 4) }] ); - expect(deps.segmentGroups.updateSegment).toHaveBeenCalledTimes(1); expect(deps.openVolumeUrls).toHaveBeenCalledTimes(1); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ urls: [file.url], @@ -396,25 +355,47 @@ describe('autoLoadProcessingResults', () => { it('does not auto-apply an unknown intent', async () => { await autoLoad([result({ intent: 'add-polygon' })], context('parent')); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(deps.openVolumeUrls).not.toHaveBeenCalled(); }); + it('says which result was skipped and why, naming the intent', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // The name a 0.2.0 backend still emits for what is now import-segmentation. + await autoLoad( + [result({ name: 'seg.nrrd', intent: 'add-segment-group' })], + context('parent') + ); + expect(errorMessages()).toEqual([ + expect.objectContaining({ + title: 'Did not load seg.nrrd', + options: expect.objectContaining({ + details: expect.stringContaining('add-segment-group'), + }), + }), + ]); + }); + + it('stays quiet about a result that declares no intent', async () => { + await autoLoad([result()], context('parent')); + expect(errorMessages()).toEqual([]); + }); + it('opens base images even when there is no originating dataset', async () => { await autoLoad([result({ intent: 'add-base-image' })], context(undefined)); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ urls: [file.url], names: [file.name], }); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); }); it('opens a parentless segment-group result as an ordinary dataset', async () => { await autoLoad( - [result({ intent: 'add-segment-group' })], + [result({ intent: 'import-segmentation' })], context(undefined) ); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ urls: [file.url], names: [file.name], @@ -423,17 +404,17 @@ describe('autoLoadProcessingResults', () => { it('keeps applying after one segment-group result throws', async () => { const err = vi.spyOn(console, 'error').mockImplementation(() => {}); - deps.segmentGroups.convertImageToLabelmap + deps.segmentWriter.convertImageToLabelmap .mockRejectedValueOnce(new Error('boom')) - .mockResolvedValueOnce(['g2']); + .mockResolvedValueOnce([importedComponent({ 1: 'segment-g2' })]); const application = await autoLoad( [ - result({ id: 'a', intent: 'add-segment-group' }), - result({ id: 'b', intent: 'add-segment-group' }), + result({ id: 'a', intent: 'import-segmentation' }), + result({ id: 'b', intent: 'import-segmentation' }), ], context('parent') ); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledTimes(2); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(2); expect(err).toHaveBeenCalled(); expect(application.failedResultIds).toEqual(['a']); }); @@ -458,18 +439,18 @@ describe('autoLoadProcessingResults', () => { jobId: 'j1', outputId: 'new', }; - deps.segmentGroups.resultSourcesInScene.mockReturnValue([restoredSource]); + deps.segmentWriter.resultSourcesInScene.mockReturnValue([restoredSource]); const application = await autoLoad( [ result({ id: 'restored', - intent: 'add-segment-group', + intent: 'import-segmentation', source: restoredSource, }), result({ id: 'new', - intent: 'add-segment-group', + intent: 'import-segmentation', source: newSource, }), ], @@ -478,30 +459,34 @@ describe('autoLoadProcessingResults', () => { expect(application.failedResultIds).toEqual([]); expect(deps.importVolume).toHaveBeenCalledTimes(1); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledTimes(1); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - newSource + newSource, + undefined ); }); }); describe('autoLoadProcessingResults — labelmap auto-apply', () => { const segResult = (overrides: Partial = {}) => - result({ id: 'seg', intent: 'add-segment-group', ...overrides }); + result({ id: 'seg', intent: 'import-segmentation', ...overrides }); it('auto-applies an importable labelmap', async () => { - deps.segmentGroups.convertImageToLabelmap.mockResolvedValue(['seg-group']); + deps.segmentWriter.convertImageToLabelmap.mockResolvedValue([ + importedComponent({ 1: 'segment-1' }), + ]); await autoLoad([segResult()], context('parent')); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledTimes(1); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); }); it('lets the conversion path decide whether an imported labelmap can attach', async () => { await autoLoad([segResult()], context('parent')); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', + undefined, undefined ); }); @@ -509,7 +494,7 @@ describe('autoLoadProcessingResults — labelmap auto-apply', () => { it('does not auto-apply a result that fails to decode, and surfaces the failure', async () => { deps.importVolume.mockResolvedValue(null); await autoLoad([segResult()], context('parent')); - expect(deps.segmentGroups.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(errorMessages()).toHaveLength(1); }); }); @@ -517,15 +502,18 @@ describe('autoLoadProcessingResults — labelmap auto-apply', () => { describe('autoLoadProcessingResults — born-persistent (no confirm gate)', () => { it('applies the group immediately with no confirm gate', async () => { const source = { providerId: 'p1', jobId: 'j1', outputId: 'seg' }; - deps.segmentGroups.convertImageToLabelmap.mockResolvedValue(['seg-group']); + deps.segmentWriter.convertImageToLabelmap.mockResolvedValue([ + importedComponent({ 1: 'segment-1' }), + ]); await autoLoad( - [result({ id: 'seg', intent: 'add-segment-group', source })], + [result({ id: 'seg', intent: 'import-segmentation', source })], context('parent') ); - expect(deps.segmentGroups.convertImageToLabelmap).toHaveBeenCalledWith( + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - source + source, + undefined ); }); }); diff --git a/src/processing/__tests__/declaredEmptySegments.spec.ts b/src/processing/__tests__/declaredEmptySegments.spec.ts new file mode 100644 index 000000000..7309da852 --- /dev/null +++ b/src/processing/__tests__/declaredEmptySegments.spec.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; + +import type { SegmentDescriptor } from '@/backend-contract'; +import { + applyIntent, + appApplyDependencies, +} from '@/src/processing/applyResults'; +import { listMasks } from '@/src/segmentation/model'; +import { isEmptyExtent } from '@/src/segmentation/geometry'; +import { segmentRenderMask } from '@/src/segmentation/rendering/renderMask'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { + inMemoryArtifactIO, + manifestForImages, + markedVoxels, + parentImage, + seatImage, + serializeToStateFiles, + store, + type Index3, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +// --------------------------------------------------------------------------- +// A result declares the bins its run looked for, and the labelmap carries the +// voxels it found. The maintainer's decision: a segment a result DECLARES but +// leaves EMPTY still appears, as an empty row. A run that looked for a spleen +// and found none must not read the same as a run that never looked, so the +// declaration is what mints the segment, not the voxels. +// --------------------------------------------------------------------------- + +const GRID = { dimensions: [4, 4, 4] as Index3 }; +const LIVER_INDEX: Index3 = [1, 1, 1]; +const red = [255, 0, 0, 255] as [number, number, number, number]; +const blue = [0, 0, 255, 255] as [number, number, number, number]; + +const DECLARED: SegmentDescriptor[] = [ + { value: 1, name: 'Liver', color: red }, + { value: 2, name: 'Spleen', color: blue }, +]; + +const registry = () => useSegmentStore().segments; + +const importResult = (segments: SegmentDescriptor[]) => + applyIntent( + { + intent: 'import-segmentation', + id: 'result', + name: 'output.nrrd', + url: 'https://example/output.nrrd', + segments, + source: { providerId: 'provider', jobId: 'job', outputId: 'mask' }, + }, + { + jobId: 'job', + taskId: 'task', + providerId: 'provider', + submittedAt: '', + activeDatasetId: 'parent', + }, + { + ...appApplyDependencies(), + importVolume: async () => 'output', + removeDataset: (id) => useImageCacheStore().removeImage(id), + } + ); + +/** What each mask on an image is named, how it looks, and where its voxels sit. */ +const segmentsOn = (imageId: string) => + listMasks(store().getSegmentationForImage(imageId)!).map((segment) => { + const appearance = registry().appearanceOf(segment.segmentId); + return { + name: appearance.name, + color: [...appearance.color], + visible: appearance.visible, + extent: segment.representations.labelmap + ? [...segment.representations.labelmap.extent] + : undefined, + marks: markedVoxels(segment.id), + }; + }); + +/** The two rows every import in this file has to produce, spelled out once. */ +const LIVER_ROW = { + name: 'Liver', + color: red, + visible: true, + extent: [1, 1, 1, 1, 1, 1], + marks: [[...LIVER_INDEX, SEGMENT_VALUE]], +}; +const EMPTY_SPLEEN_ROW = { + name: 'Spleen', + color: blue, + visible: true, + extent: [0, -1, 0, -1, 0, -1], + marks: [], +}; + +const liverOffset = () => + LIVER_INDEX[0] + LIVER_INDEX[1] * 4 + LIVER_INDEX[2] * 16; + +/** Only value 1 is written, so value 2 is declared and never filled. */ +async function seatScene() { + await seatImage('parent', { ...GRID, name: 'CT' }); + const values = new Uint8Array(64); + values[liverOffset()] = 1; + await seatImage('output', { ...GRID, name: 'output.nrrd', values }); +} + +describe('a segment a result declares but leaves empty', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatScene(); + }); + + it('appears as an empty row beside the segment that has voxels', async () => { + expect(await importResult(DECLARED)).toEqual({ status: 'applied' }); + + expect(segmentsOn('parent')).toEqual([LIVER_ROW, EMPTY_SPLEEN_ROW]); + // The empty row is a real mask record, holding no voxels. + const spleen = listMasks(store().getSegmentationForImage('parent')!)[1]; + expect(store().maskVoxels(spleen.id).scalars()).toHaveLength(0); + }); + + it('draws nothing for the empty segment', async () => { + await importResult(DECLARED); + + const spleen = listMasks(store().getSegmentationForImage('parent')!)[1]; + const binding = spleen.representations.labelmap!; + expect(isEmptyExtent(binding.extent)).toBe(true); + expect( + segmentRenderMask(binding.image, parentImage('parent'), binding.extent, { + axis: 2, + index: 1, + }) + ).toBeNull(); + }); + + it.each([0, 1])( + 'mints no empty twin for a value only component %i carries', + async (component) => { + // A value with voxels in one component and none in the other is not a + // declaration left empty: some component found it. Only the value no + // component carries becomes a row of its own. + const values = new Uint8Array(128); + values[liverOffset() * 2 + component] = 1; + useImageCacheStore() + .getVtkImageData('output')! + .getPointData() + .setScalars( + vtkDataArray.newInstance({ numberOfComponents: 2, values }) + ); + + expect(await importResult(DECLARED)).toEqual({ status: 'applied' }); + + // Compared whole: a twin is not the only way this can go wrong, and a + // mis-coloured, hidden or wrongly bounded empty must fail here too. + expect(segmentsOn('parent')).toEqual([LIVER_ROW, EMPTY_SPLEEN_ROW]); + } + ); + + it('keeps both segments across a save and restore', async () => { + await importResult(DECLARED); + const before = segmentsOn('parent'); + // Stated outright, so the comparison below cannot pass on a scene that + // dropped the empty row before it was ever saved. + expect(before).toEqual([LIVER_ROW, EMPTY_SPLEEN_ROW]); + const io = inMemoryArtifactIO(); + + const { parsed, stateFiles } = await serializeToStateFiles( + manifestForImages(['parent']), + io + ); + + setActivePinia(createPinia()); + await seatImage('new-parent', { ...GRID, name: 'CT' }); + const result = await useSegmentationStore().deserialize({ + manifest: parsed, + stateFiles, + dataIDMap: { parent: 'new-parent' }, + segmentIdMap: useSegmentStore().deserialize(parsed), + io, + }); + await nextTick(); + + expect(result.skipped).toEqual([]); + expect(segmentsOn('new-parent')).toEqual(before); + }); +}); diff --git a/src/processing/applyResults.ts b/src/processing/applyResults.ts index 0c07392d9..5abeff728 100644 --- a/src/processing/applyResults.ts +++ b/src/processing/applyResults.ts @@ -4,7 +4,6 @@ import { type AnnotationToolKind, type KnownResultIntent, type ResultSource, - type SegmentDescriptor, type WirePolygon, type WireRuler, } from '@/backend-contract'; @@ -19,7 +18,7 @@ import { } from '@/src/processing/engine/annotationsWire'; import { fetchProcessingResult } from '@/src/processing/engine/resultDownload'; import { annotationToolStore } from '@/src/processing/annotationKinds'; -import { cleanUndefined, ensureError } from '@/src/utils'; +import { cleanUndefined, ensureError, plural } from '@/src/utils'; import { frameOfReferenceToImageSliceAndAxis } from '@/src/utils/frameOfReference'; import { uriToDataSource } from '@/src/io/import/dataSource'; import { @@ -28,20 +27,21 @@ import { } from '@/src/io/import/importDataSources'; import { isVolumeResult } from '@/src/io/import/common'; import type { ImageMetadata } from '@/src/types/image'; -import type { SegmentMask } from '@/src/types/segment'; +import { listMasks } from '@/src/segmentation/model'; +import { tryCssColorToRGBA } from '@/src/segmentation/color'; import { useDatasetStore } from '@/src/store/datasets'; 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 { useImageCacheStore } from '@/src/store/image-cache'; -import { useMessageStore } from '@/src/store/messages'; +import { surfaceWarning, useMessageStore } from '@/src/store/messages'; import { loadVolumeUrls } from '@/src/actions/loadUserFiles'; type ResultFile = { url: string; name: string }; -type SegmentGroupIntent = Extract< +type SegmentationIntent = Extract< KnownResultIntent, - { intent: 'add-segment-group' } + { intent: 'import-segmentation' } >; type AnnotationsIntent = Extract< KnownResultIntent, @@ -59,13 +59,13 @@ const sameResultSource = ( source.jobId === target.jobId && source.outputId === target.outputId; -function segmentGroupResultInScene( - intent: SegmentGroupIntent, - segmentGroups: SegmentGroupWriter +function segmentResultInScene( + intent: SegmentationIntent, + segmentWriter: SegmentWriter ): boolean { const target = intent.source; if (!target) return false; - return segmentGroups + return segmentWriter .resultSourcesInScene() .some((source) => sameResultSource(source, target)); } @@ -79,46 +79,6 @@ async function loadAsImport(file: ResultFile) { return loaded[0] ? toDataSelection(loaded[0]) : null; } -function applySegmentDescriptors( - segmentGroupID: string, - segments: SegmentDescriptor[], - segmentGroups: SegmentGroupWriter -) { - segments.forEach((seg) => { - try { - segmentGroups.updateSegment(segmentGroupID, seg.value, { - name: seg.name, - color: seg.color, - ...(seg.visible == null ? {} : { visible: seg.visible }), - }); - } catch (err) { - // Decoded segment list may not cover every value in the labelmap. - - console.warn('Failed to apply segment descriptor', seg, err); - } - }); -} - -async function convertAndDescribe( - childSelection: string, - parentSelection: string, - intent: SegmentGroupIntent, - segmentGroups: SegmentGroupWriter -): Promise { - const ids = await segmentGroups.convertImageToLabelmap( - childSelection, - parentSelection, - intent.source - ); - // A seg.nrrd with embedded metadata carries no descriptors. - if (intent.segments?.length) { - ids.forEach((id) => - applySegmentDescriptors(id, intent.segments!, segmentGroups) - ); - } - return ids; -} - // Annotation results are fully decoded and located before labels or tools are // mutated. Store payloads remain explicit allowlists of decoded fields. @@ -258,41 +218,69 @@ const prepareAnnotations = ( ]) ) as PreparedAnnotations; -// Label identity across the boundary is the NAME, inside its own tool-kind -// namespace: merging returns the store id a tool must point at. Only names the -// tools actually reference are merged — a declaration nothing uses would be -// clutter in the label picker. -const mergeReferencedLabels = ( +// A colour the parser does not know would otherwise resolve to opaque black, +// which reads as a deliberate choice by the task. The segment keeps whatever +// colour it already has instead, and the label's claim is recorded so the user +// hears about it. +const segmentInit = ( + name: string, + style: AnnotationLabel, + rejected: Set +) => { + const color = style.color ? tryCssColorToRGBA(style.color) : undefined; + if (style.color && !color) rejected.add(`${name} (${style.color})`); + return { + ...(color ? { color } : {}), + ...(style.strokeWidth === undefined + ? {} + : { strokeWidth: style.strokeWidth }), + }; +}; + +// Reported at the boundary that owns the file, naming the label and what it +// said, the way an imported config reports the same mistake. +const reportUnparseableColors = (rejected: Set) => { + if (rejected.size === 0) return; + useMessageStore().addError( + `Unrecognized ${plural(rejected.size, 'color')} in result labels: ` + + `${[...rejected].join(', ')}. ` + + 'Use a hex value such as #d60000, or a CSS color keyword.' + ); +}; + +// Type identity across the boundary is the NAME, inside its own registry: +// binding returns the type id a tool must point at, minting on a miss. Only +// names the tools actually reference are bound — a declaration nothing uses +// would be clutter in the picker. +const bindReferencedSegments = ( kind: AnnotationToolKind, tools: readonly PreparedCore[], - namespace: Record + namespace: Record, + rejected: Set ): Record => { - const store = annotationToolStore(kind); + const { segments } = annotationToolStore(kind); const names = new Set( tools.flatMap((tool) => (tool.labelName ? [tool.labelName] : [])) ); - // A merge that lands on a new name adds a label, and adding one activates it. - // Applying a result is not the user picking a label, so the picker is put back. - const activeBefore = store.activeLabel; - const ids = Object.fromEntries( - [...names].map((labelName) => [ - labelName, - store.mergeLabel({ labelName, ...(namespace[labelName] ?? {}) }), + return Object.fromEntries( + [...names].map((name) => [ + name, + segments.segmentNamed( + name, + segmentInit(name, namespace[name] ?? {}, rejected) + ), ]) ); - store.setActiveLabel(activeBefore); - return ids; }; -// `labelName` is deliberately NOT passed through: addTool re-derives it from -// the label id, and passing a name without an id would silently blank it. +// `labelName` names the type, which the tool carries by id. const toolPayload = ( { labelName, ...core }: PreparedCore, - labelIds: Record, + segmentIds: Record, source: ResultSource | undefined ) => ({ ...core, - label: (labelName && labelIds[labelName]) || '', + segmentId: (labelName && segmentIds[labelName]) || '', ...(source ? { source } : {}), }); @@ -336,14 +324,21 @@ async function applyAnnotations( return { status: 'applied' }; } - // Labels first for every kind, then the tools: a tool points at the store id - // its label merged to. - const labelIds = Object.fromEntries( + // Types first for every kind, then the tools: a tool points at the type id + // its name bound to. + const rejectedColors = new Set(); + const segmentIds = Object.fromEntries( ANNOTATION_TOOL_KINDS.map((kind) => [ kind, - mergeReferencedLabels(kind, prepared[kind], decoded.labels[kind]), + bindReferencedSegments( + kind, + prepared[kind], + decoded.labels[kind], + rejectedColors + ), ]) ) as Record>; + reportUnparseableColors(rejectedColors); ANNOTATION_TOOL_KINDS.forEach((kind) => { const store = annotationToolStore(kind); @@ -352,7 +347,7 @@ async function applyAnnotations( // uniform tool type does not carry the per-kind geometry keys. const payload = { ...geometry, - ...toolPayload(core, labelIds[kind], intent.source), + ...toolPayload(core, segmentIds[kind], intent.source), }; store.addTool(payload); }); @@ -363,19 +358,12 @@ async function applyAnnotations( type FetchProcessingResult = typeof fetchProcessingResult; -type SegmentGroupWriter = { - /** Result provenance of every segment group in the scene, in scene order. */ +type SegmentWriter = { + /** Result provenance of every mask in the scene, in scene order. */ resultSourcesInScene: () => Array; - convertImageToLabelmap: ( - childSelection: string, - parentSelection: string, - source: ResultSource | undefined - ) => Promise; - updateSegment: ( - segmentGroupID: string, - segmentValue: number, - segmentUpdate: Partial> - ) => void; + convertImageToLabelmap: ReturnType< + typeof useSegmentationStore + >['convertImageToLabelmap']; }; /** @@ -391,7 +379,7 @@ export type ApplyDependencies = { parentSelection: string, childSelection: string ) => Promise; - segmentGroups: SegmentGroupWriter; + segmentWriter: SegmentWriter; }; export const appApplyDependencies = (): ApplyDependencies => ({ @@ -401,23 +389,13 @@ export const appApplyDependencies = (): ApplyDependencies => ({ removeDataset: (selection) => useDatasetStore().remove(selection), addLayer: (parentSelection, childSelection) => useLayersStore().addLayer(parentSelection, childSelection), - segmentGroups: { + segmentWriter: { resultSourcesInScene: () => - Object.values(useSegmentGroupStore().metadataByID).map( - ({ source }) => source - ), - convertImageToLabelmap: (childSelection, parentSelection, source) => - useSegmentGroupStore().convertImageToLabelmap( - childSelection, - parentSelection, - source - ), - updateSegment: (segmentGroupID, segmentValue, segmentUpdate) => - useSegmentGroupStore().updateSegment( - segmentGroupID, - segmentValue, - segmentUpdate - ), + Object.values(useSegmentationStore().segmentations) + .flatMap((segmentation) => listMasks(segmentation)) + .map((segment) => segment.representations.labelmap?.source), + convertImageToLabelmap: (...args) => + useSegmentationStore().convertImageToLabelmap(...args), }, }); @@ -465,11 +443,11 @@ export async function applyIntent( } return { status: 'applied' }; } - case 'add-segment-group': { + case 'import-segmentation': { // Session-restored groups retain their result source. Treat that // durable provenance as an application receipt so retrying Load is // idempotent instead of creating a duplicate group. - if (segmentGroupResultInScene(intent, dependencies.segmentGroups)) + if (segmentResultInScene(intent, dependencies.segmentWriter)) return { status: 'applied' }; if (!parentSelection) { return await openVolumeAsDatasetOutcome(intent); @@ -478,11 +456,11 @@ export async function applyIntent( if (!childSelection) return { status: 'failed', error: new Error('Result did not load') }; try { - await convertAndDescribe( + await dependencies.segmentWriter.convertImageToLabelmap( childSelection, parentSelection, - intent, - dependencies.segmentGroups + intent.source, + intent.segments ); return { status: 'applied' }; } finally { @@ -511,6 +489,17 @@ export async function applyIntent( } } +// The applier routes on the declared intent, so a result carrying one it +// cannot read is skipped. Say so: the completion toast has already promised +// the results, and the skip otherwise leaves a plain download and no reason. +function reportUnroutableIntent(result: ProcessingResult) { + if (!result.intent) return; + surfaceWarning( + `Did not load ${result.name}`, + `This version cannot apply the result intent "${result.intent}". The result is still available for download in the Jobs panel.` + ); +} + export async function autoLoadProcessingResults( results: ProcessingResult[], context: SubmittedJobContext | undefined, @@ -519,7 +508,10 @@ export async function autoLoadProcessingResults( const failedResultIds: string[] = []; for (const result of results) { const intent = resultToIntent(result); - if (!intent) continue; + if (!intent) { + reportUnroutableIntent(result); + continue; + } const outcome = await applyIntent(intent, context, dependencies); if (outcome.status === 'failed') { failedResultIds.push(result.id); diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index a01790843..9b3574001 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -90,6 +90,7 @@ :source-ref-states="sourceRefStates" :source-ref-names="sourceRefNames" :source-ref-types="sourceRefTypes" + :source-ref-warnings="inputWarnings" :submitting="submitting" @update:values="onValuesUpdate" @submit="onSubmit" @@ -112,6 +113,12 @@ diff --git a/src/segmentation/components/SegmentAssignmentList.vue b/src/segmentation/components/SegmentAssignmentList.vue new file mode 100644 index 000000000..e1405c4ac --- /dev/null +++ b/src/segmentation/components/SegmentAssignmentList.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/segmentation/components/SegmentEditor.vue b/src/segmentation/components/SegmentEditor.vue new file mode 100644 index 000000000..7b4b0691b --- /dev/null +++ b/src/segmentation/components/SegmentEditor.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/segmentation/components/SegmentList.vue b/src/segmentation/components/SegmentList.vue new file mode 100644 index 000000000..c4814890b --- /dev/null +++ b/src/segmentation/components/SegmentList.vue @@ -0,0 +1,559 @@ + + + + + diff --git a/src/segmentation/components/SegmentListActions.vue b/src/segmentation/components/SegmentListActions.vue new file mode 100644 index 000000000..4fe08a3f2 --- /dev/null +++ b/src/segmentation/components/SegmentListActions.vue @@ -0,0 +1,101 @@ + + + diff --git a/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts new file mode 100644 index 000000000..768cf881b --- /dev/null +++ b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createApp, defineComponent, nextTick } from 'vue'; +import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; + +import ProcessWorkflow from '@/src/segmentation/components/ProcessWorkflow.vue'; +import { CorePiniaProviderPlugin } from '@/src/core/provider'; +import { + addActiveSegment, + seatImage, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { + usePaintProcessStore, + type ProcessTarget, +} from '@/src/segmentation/editing/paintProcess'; +import { useViewStore } from '@/src/store/views'; + +// --------------------------------------------------------------------------- +// The Original/Processed pair is a segmented choice, not a switch: the toggle +// is mandatory, so clicking the button already selected keeps the selection +// but still fires the click. Each button therefore states what it shows. +// --------------------------------------------------------------------------- + +const BtnStub = defineComponent({ + name: 'VBtn', + props: ['value', 'prependIcon', 'loading', 'disabled', 'variant'], + template: ``, +}); + +const BtnToggleStub = defineComponent({ + name: 'VBtnToggle', + props: ['modelValue', 'mandatory', 'variant', 'divided', 'density'], + template: `
`, +}); + +const globalOptions = { + stubs: { + VRow: { template: '
' }, + VBtn: BtnStub, + VBtnToggle: BtnToggleStub, + // The icon name is slot text, which would land in the button's label. + VIcon: { template: '' }, + }, +}; + +const processed = async (target: ProcessTarget) => ({ + scalars: new Uint8Array([1, 1]), + extent: target.maskExtent, +}); + +const button = (wrapper: VueWrapper, label: string) => { + const found = wrapper + .findAll('button') + .find((candidate) => candidate.text().trim() === label); + if (!found) throw new Error(`No ${label} button`); + return found; +}; + +const selected = (wrapper: VueWrapper) => + wrapper.get('.btn-toggle').attributes('data-selected'); + +describe('the process preview toggle', () => { + beforeEach(async () => { + const pinia = createPinia().use(CorePiniaProviderPlugin()); + createApp({}).use(pinia); + setActivePinia(pinia); + await seatImage('image-1', { dimensions: [2, 1, 1] }); + useViewStore().setDataForAllViews('image-1'); + await nextTick(); + }); + + /** A run already previewing its result, with the workflow mounted on it. */ + const previewing = async () => { + const { labelMap } = addActiveSegment(new Uint8Array([1, 0])); + const wrapper = mount(ProcessWorkflow, { + props: { algorithm: processed }, + global: globalOptions, + }); + await button(wrapper, 'Preview').trigger('click'); + await flushPromises(); + const values = () => + Array.from(labelMap.getPointData().getScalars().getData()); + expect(usePaintProcessStore().processStep).toBe('previewing'); + return { wrapper, values, processStore: usePaintProcessStore() }; + }; + + it('leaves the preview alone when the showing button is clicked again', async () => { + const { wrapper, values, processStore } = await previewing(); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + + await button(wrapper, 'Processed').trigger('click'); + + expect(processStore.showingOriginal).toBe(false); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + }); + + it('shows the original once, however often its button is clicked', async () => { + const { wrapper, values, processStore } = await previewing(); + + await button(wrapper, 'Original').trigger('click'); + + expect(processStore.showingOriginal).toBe(true); + expect(selected(wrapper)).toBe('0'); + expect(values()).toEqual([1, 0]); + + await button(wrapper, 'Original').trigger('click'); + + expect(processStore.showingOriginal).toBe(true); + expect(selected(wrapper)).toBe('0'); + expect(values()).toEqual([1, 0]); + }); + + it('still moves between the two', async () => { + const { wrapper, values, processStore } = await previewing(); + + await button(wrapper, 'Original').trigger('click'); + await button(wrapper, 'Processed').trigger('click'); + + expect(processStore.showingOriginal).toBe(false); + expect(selected(wrapper)).toBe('1'); + expect(values()).toEqual([1, 1]); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentEditor.spec.ts b/src/segmentation/components/__tests__/SegmentEditor.spec.ts new file mode 100644 index 000000000..98c5b64a8 --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentEditor.spec.ts @@ -0,0 +1,92 @@ +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; + +import SegmentEditor from '@/src/segmentation/components/SegmentEditor.vue'; + +const LabelEditorStub = defineComponent({ + name: 'LabelEditor', + props: ['color', 'valid'], + setup: () => ({ done: () => {} }), + template: '
', +}); + +const TextFieldStub = defineComponent({ + name: 'VTextField', + props: ['modelValue', 'rules'], + template: '', +}); + +const SliderStub = defineComponent({ + name: 'VSlider', + props: ['label', 'modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: '
', +}); + +const mountEditor = () => + mount(SegmentEditor, { + props: { + name: 'Tumor', + original: 'Tumor', + color: '#ff0000', + invalidNames: new Set(['Tumor', 'Node']), + fillOpacity: 1, + outlineOpacity: 1, + strokeWidth: 1, + }, + global: { + stubs: { + LabelEditor: LabelEditorStub, + VTextField: TextFieldStub, + VSlider: SliderStub, + }, + }, + }); + +describe('segment type editor name validation', () => { + it('allows an unchanged duplicate name', () => { + const wrapper = mountEditor(); + + expect(wrapper.findComponent(LabelEditorStub).props('valid')).toBe(true); + const [rule] = wrapper.findComponent(TextFieldStub).props('rules'); + expect(rule('Tumor')).toBe(true); + }); + + it('rejects changing to another type’s name', async () => { + const wrapper = mountEditor(); + + await wrapper.setProps({ name: ' Node ' }); + + expect(wrapper.findComponent(LabelEditorStub).props('valid')).toBe(false); + const [rule] = wrapper.findComponent(TextFieldStub).props('rules'); + expect(rule(' Node ')).toBe('Name is not unique'); + }); +}); + +describe('segment type editor stroke width', () => { + it('offers integer stroke widths from 1 through 5', () => { + const wrapper = mountEditor(); + const strokeWidth = wrapper + .findAllComponents(SliderStub) + .find((slider) => slider.props('label') === 'Stroke Width'); + + expect(strokeWidth?.props()).toMatchObject({ + modelValue: 1, + min: 1, + max: 5, + step: 1, + }); + }); + + it('emits an integer stroke width', () => { + const wrapper = mountEditor(); + const strokeWidth = wrapper + .findAllComponents(SliderStub) + .find((slider) => slider.props('label') === 'Stroke Width')!; + + strokeWidth.vm.$emit('update:modelValue', 3.6); + + expect(wrapper.emitted('update:strokeWidth')).toEqual([[4]]); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentList.spec.ts b/src/segmentation/components/__tests__/SegmentList.spec.ts new file mode 100644 index 000000000..06e1e23fe --- /dev/null +++ b/src/segmentation/components/__tests__/SegmentList.spec.ts @@ -0,0 +1,1394 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + type Index3, + maskOn, + lockSegment, + boundMasks, + seedVoxel, + markedVoxels, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { defineComponent, nextTick, ref } from 'vue'; +import { enableAutoUnmount, mount, VueWrapper } from '@vue/test-utils'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import SegmentList from '@/src/segmentation/components/SegmentList.vue'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { DEFAULT_SEGMENTATION_FILL_OPACITY } from '@/src/segmentation/model'; +import { extentSize, maskOffset } from '@/src/segmentation/geometry'; +import { useViewStore } from '@/src/store/views'; +import useViewSliceStore from '@/src/store/view-configs/slicing'; +import { seatCineImage } from '@/src/core/cine/__tests__/cineFixtures'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { + useCurrentTools, + usePlacingAnnotationTool, +} from '@/src/composables/annotationTool'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { AXIAL_FRAME_OF_REFERENCE } from '@/src/utils/frameOfReference'; +import useCinePlaybackStore from '@/src/store/view-configs/cine-playback'; +import useViewCameraStore from '@/src/store/view-configs/camera'; + +enableAutoUnmount(afterEach); + +// --------------------------------------------------------------------------- +// One flat list of segment types: rows are the shared registry's segments, keyed +// on type id, offered whether or not this image has a mask for them. The +// visibility and lock controls belong to the shared segment, the +// display sliders to its segmentation, and adding a row allocates nothing. +// --------------------------------------------------------------------------- + +const DIMENSIONS = [4, 4, 2] as const; + +const store = () => useSegmentationStore(); +const segments = () => useSegmentStore().segments; + +async function seatImage( + id: string, + name = 'CT', + dimensions: readonly [number, number, number] = DIMENSIONS +) { + const image = vtkImageData.newInstance({ spacing: [1, 1, 1] }); + image.setDimensions(dimensions as unknown as [number, number, number]); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(dimensions[0] * dimensions[1] * dimensions[2]), + }) + ); + image.computeTransforms(); + useImageCacheStore().addVTKImageData(image, name, { id }); + await nextTick(); + return id; +} + +const viewImage = async (id: string) => { + useViewStore().setDataForAllViews(id); + await nextTick(); +}; + +/** A type with a mask on one image: what a painted segment looks like. */ +const makeMask = (imageId: string, name: string) => { + const segmentId = segments().mintSegment({ name }); + const record = maskOn(imageId, segmentId); + return { id: segmentId, segmentId, maskId: record.id, record }; +}; + +/** A type with no mask anywhere, which the list still offers. */ +const makeSegment = (name: string) => segments().mintSegment({ name }); + +// The item list stands in for the real one so the per-row slot renders without +// Vuetify: rows carry their segment id, and the row buttons keep the icon names +// the list uses today. +const ItemListStub = defineComponent({ + name: 'EditableItemList', + props: [ + 'items', + 'itemKey', + 'itemTitle', + 'modelValue', + 'createText', + 'hideCreate', + ], + emits: ['update:model-value', 'create', 'select', 'edit'], + template: ` +
+
+ + +
+
+ `, +}); + +const BtnStub = defineComponent({ + name: 'VBtn', + props: ['icon', 'disabled'], + template: ``, +}); + +const IconStub = defineComponent({ + name: 'VIcon', + template: ``, +}); + +const SegmentEditorStub = defineComponent({ + name: 'SegmentEditor', + props: [ + 'name', + 'original', + 'color', + 'fillOpacity', + 'outlineOpacity', + 'strokeWidth', + 'invalidNames', + 'locked', + ], + emits: [ + 'done', + 'cancel', + 'delete', + 'update:name', + 'update:color', + 'update:fillOpacity', + 'update:outlineOpacity', + 'update:strokeWidth', + ], + template: `
`, +}); + +// Sliders are found by the label the user reads. +const SliderStub = defineComponent({ + name: 'VSlider', + props: ['label', 'modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ``, +}); + +const globalOptions = { + stubs: { + VSlider: SliderStub, + VExpansionPanels: { template: '
' }, + VExpansionPanel: { template: '
' }, + VExpansionPanelTitle: { template: '' }, + VExpansionPanelText: { template: '
' }, + EditableItemList: ItemListStub, + SegmentEditor: SegmentEditorStub, + IsolatedDialog: { template: '
' }, + CloseableDialog: { + props: ['modelValue'], + template: + '
', + }, + SaveSegmentationDialog: { props: ['id'], template: '
' }, + VBtn: BtnStub, + VIcon: IconStub, + VSpacer: { template: '' }, + VTooltip: { template: '' }, + }, +}; + +const mountList = () => mount(SegmentList, { global: globalOptions }); + +const itemList = (wrapper: VueWrapper) => wrapper.findComponent(ItemListStub); + +const rowIds = (wrapper: VueWrapper) => + wrapper.findAll('.item-row').map((row) => row.attributes('data-id')); + +const rowButton = (wrapper: VueWrapper, id: string, icons: string[]) => { + const row = wrapper.find(`[data-id="${id}"]`); + if (!row.exists()) throw new Error(`No row for segment ${id}`); + const button = row + .findAll('button') + .find((candidate) => + icons.includes( + candidate.attributes('data-icon') || candidate.text().trim() + ) + ); + if (!button) throw new Error(`No ${icons.join('/')} button on row ${id}`); + return button; +}; + +const editor = (wrapper: VueWrapper) => + wrapper.findComponent(SegmentEditorStub); + +// Reveal carries its icon in the slot beside its tooltip, so the icon-name +// lookup the other row buttons use does not reach it. +const revealButton = (wrapper: VueWrapper, id: string) => { + const button = wrapper.find( + `[data-id="${id}"] [data-testid="reveal-segment-button"]` + ); + if (!button.exists()) throw new Error(`No reveal button on row ${id}`); + return button; +}; + +describe('flat segment list', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + it('lists the registry in creation order, keyed by type id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + + const wrapper = mountList(); + await nextTick(); + + expect(rowIds(wrapper)).toEqual([first.id, second.id]); + expect(itemList(wrapper).props('itemKey')).toBe('id'); + expect( + itemList(wrapper) + .props('items') + .map((item: { name: string }) => item.name) + ).toEqual(['Tumor', 'Node']); + }); + + it('lists a type that has no voxels yet', async () => { + const unbound = makeMask('img-1', 'Tumor'); + + const wrapper = mountList(); + await nextTick(); + + expect(unbound.record.representations.labelmap).toBeUndefined(); + expect(rowIds(wrapper)).toEqual([unbound.id]); + }); + + it('offers a type with no mask on this image', async () => { + const onTwo = makeMask('img-2', 'Node'); + const everywhere = makeSegment('Tumor'); + + const wrapper = mountList(); + await nextTick(); + + expect(rowIds(wrapper)).toEqual([onTwo.id, everywhere]); + expect(store().getSegmentationForImage('img-1')).toBeUndefined(); + }); + + it('keeps the same rows when the viewed image changes', async () => { + const onOne = makeMask('img-1', 'Tumor'); + const onTwo = makeMask('img-2', 'Node'); + + const wrapper = mountList(); + await nextTick(); + expect(rowIds(wrapper)).toEqual([onOne.id, onTwo.id]); + + await viewImage('img-2'); + + expect(rowIds(wrapper)).toEqual([onOne.id, onTwo.id]); + }); + + it('creates no segmentation for an image it renders', async () => { + const onOne = makeMask('img-1', 'Tumor'); + segments().selectSegment(onOne.segmentId); + await viewImage('img-2'); + + mountList(); + await nextTick(); + + expect(store().getSegmentationForImage('img-2')).toBeUndefined(); + // Rendering an empty list is not a deselection. + expect(segments().selectedSegmentId.value).toBe(onOne.segmentId); + }); + + it('leaves the selected type alone when it mounts', async () => { + const first = makeMask('img-1', 'Tumor'); + makeMask('img-1', 'Node'); + segments().selectSegment(first.segmentId); + + mountList(); + await nextTick(); + + expect(segments().selectedSegmentId.value).toBe(first.segmentId); + }); +}); + +describe('flat segment list with no viewed image', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + }); + + it('offers no list and no toggles until an image is viewed', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(wrapper.find('[data-testid="segment-list"]').exists()).toBe(false); + expect(wrapper.findAll('button')).toEqual([]); + expect(wrapper.text()).toContain('No selected image'); + }); + + it('renders the list once an image is viewed', async () => { + const wrapper = mountList(); + await nextTick(); + + await viewImage('img-1'); + + expect(wrapper.find('[data-testid="segment-list"]').exists()).toBe(true); + expect(wrapper.text()).not.toContain('No selected image'); + }); +}); + +describe('flat segment list selection', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + it('marks the selected type as the selected row', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + segments().selectSegment(second.segmentId); + + const wrapper = mountList(); + await nextTick(); + + expect(itemList(wrapper).props('modelValue')).toBe(second.segmentId); + }); + + it('selects a type by id when a row is picked', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + segments().selectSegment(first.segmentId); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('update:model-value', second.segmentId); + await nextTick(); + + expect(segments().selectedSegmentId.value).toBe(second.segmentId); + // Selecting creates nothing on any image. + expect(store().getSegmentationForImage('img-2')).toBeUndefined(); + }); + + it('keeps the selected row on an image the type has no mask on', async () => { + const onOne = makeMask('img-1', 'Tumor'); + segments().selectSegment(onOne.segmentId); + await viewImage('img-2'); + + const wrapper = mountList(); + await nextTick(); + + expect(itemList(wrapper).props('modelValue')).toBe(onOne.segmentId); + expect(store().getSegmentationForImage('img-2')).toBeUndefined(); + }); +}); + +describe('flat segment list row creation', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + it('adds a row without allocating any storage', async () => { + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(segments().segmentList.value).toHaveLength(1); + expect(store().getSegmentationForImage('img-1')).toBeUndefined(); + expect(boundMasks()).toEqual([]); + expect(rowIds(wrapper)).toEqual([segments().segmentList.value[0].id]); + }); + + it('selects the row it adds', async () => { + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(segments().selectedSegmentId.value).toBe( + segments().segmentList.value[0].id + ); + }); + + it('adds the row for every image at once', async () => { + await seatImage('img-2'); + makeMask('img-2', 'Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + itemList(wrapper).vm.$emit('create'); + await nextTick(); + + expect(rowIds(wrapper)).toHaveLength(2); + // The other image keeps the one mask it had; the new type has none. + expect(store().getSegmentationForImage('img-2')!.order).toHaveLength(1); + }); +}); + +describe('flat segment list row actions', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + it('toggles one segment’s visibility by id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + + await rowButton(wrapper, second.id, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + + expect(segments().appearanceOf(second.segmentId).visible).toBe(false); + expect(segments().appearanceOf(first.segmentId).visible).toBe(true); + }); + + it('toggles one segment’s lock by id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + + await rowButton(wrapper, second.id, ['mdi-lock', 'mdi-lock-open']).trigger( + 'click' + ); + + expect(segments().appearanceOf(second.segmentId).locked).toBe(true); + expect(segments().appearanceOf(first.segmentId).locked).toBe(false); + }); + + // The tooltip is the only place the panel can say what locking does, and the + // shared stub drops its content, so this mounts one that renders it. + const mountWithTooltips = () => + mount(SegmentList, { + global: { + stubs: { + ...globalOptions.stubs, + VTooltip: { template: '' }, + }, + }, + }); + + const lockTooltip = (wrapper: VueWrapper, id: string) => { + const button = wrapper + .find(`[data-id="${id}"]`) + .findAll('button') + .find((candidate) => + candidate + .findAll('i.icon') + .some((icon) => icon.text().trim().startsWith('mdi-lock')) + ); + if (!button) throw new Error(`No lock button on row ${id}`); + return button.find('.tooltip').text(); + }; + + it('says on the lock that it is what lets two segments share voxels', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mountWithTooltips(); + await nextTick(); + + expect(lockTooltip(wrapper, segment.id)).toMatch(/^Lock\b/); + expect(lockTooltip(wrapper, segment.id)).toMatch(/shares its voxels/i); + + lockSegment(segment.maskId, true); + await nextTick(); + + expect(lockTooltip(wrapper, segment.id)).toMatch(/^Unlock\b/); + expect(lockTooltip(wrapper, segment.id)).toMatch(/takes its voxels/i); + }); + + it('deletes one type, with the masks it had, by id', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + + await rowButton(wrapper, first.id, ['mdi-delete']).trigger('click'); + await nextTick(); + + expect(store().getSegmentationForImage('img-1')!.order).toEqual([ + second.maskId, + ]); + expect(segments().getSegment(first.segmentId)).toBeUndefined(); + expect(rowIds(wrapper)).toEqual([second.id]); + }); + + it('offers visibility and lock on every row, mask here or not', async () => { + const withMask = makeMask('img-1', 'Tumor'); + const withoutMask = makeSegment('Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + // Both describe the type, so they hold on every image and are offered on + // a row this image has painted nothing for. + [withMask.id, withoutMask].forEach((id) => { + expect(rowButton(wrapper, id, ['mdi-eye', 'mdi-eye-off']).exists()).toBe( + true + ); + expect( + rowButton(wrapper, id, ['mdi-lock', 'mdi-lock-open']).exists() + ).toBe(true); + }); + + await rowButton(wrapper, withoutMask, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + + expect(segments().appearanceOf(withoutMask).visible).toBe(false); + }); + + it('hides every type at once, on every image', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + await seatImage('img-2'); + const elsewhere = makeMask('img-2', 'Elsewhere'); + const wrapper = mountList(); + await nextTick(); + + await wrapper + .find('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + + expect(segments().appearanceOf(first.segmentId).visible).toBe(false); + expect(segments().appearanceOf(second.segmentId).visible).toBe(false); + expect(segments().appearanceOf(elsewhere.segmentId).visible).toBe(false); + }); +}); + +describe('flat segment list row editing', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + const openEditor = async (id: string) => { + const wrapper = mountList(); + await nextTick(); + await rowButton(wrapper, id, ['mdi-pencil']).trigger('click'); + await nextTick(); + return wrapper; + }; + + it('renames the row’s type by id, keeping that id', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = await openEditor(second.id); + + editor(wrapper).vm.$emit('update:name', 'Lesion'); + editor(wrapper).vm.$emit('done'); + await nextTick(); + + expect(segments().appearanceOf(second.segmentId).name).toBe('Lesion'); + expect(store().getMask(second.maskId).segmentId).toBe(second.segmentId); + }); + + it('recolors the row’s type by id', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + editor(wrapper).vm.$emit('update:color', '#0000ff'); + editor(wrapper).vm.$emit('done'); + await nextTick(); + + expect( + [...segments().appearanceOf(segment.segmentId).color].slice(0, 3) + ).toEqual([0, 0, 255]); + }); + + it('edits the type’s fill opacity, outline opacity and stroke width', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + expect(editor(wrapper).props('fillOpacity')).toBe(1); + expect(editor(wrapper).props('outlineOpacity')).toBe(1); + + editor(wrapper).vm.$emit('update:fillOpacity', 0.5); + editor(wrapper).vm.$emit('update:outlineOpacity', 0.25); + editor(wrapper).vm.$emit('update:strokeWidth', 3); + editor(wrapper).vm.$emit('done'); + await nextTick(); + + const appearance = segments().appearanceOf(segment.segmentId); + expect(appearance.fillOpacity).toBe(0.5); + expect(appearance.outlineOpacity).toBe(0.25); + expect(appearance.strokeWidth).toBe(3); + }); + + it('discards the edit when the dialog is cancelled', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + editor(wrapper).vm.$emit('update:name', 'Lesion'); + editor(wrapper).vm.$emit('update:fillOpacity', 0.5); + editor(wrapper).vm.$emit('cancel'); + await nextTick(); + + const appearance = segments().appearanceOf(segment.segmentId); + expect(appearance.name).toBe('Tumor'); + expect(appearance.fillOpacity).toBe(1); + }); + + it('offers the other rows’ names as taken', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = await openEditor(second.id); + + expect([...editor(wrapper).props('invalidNames')]).toEqual(['Tumor']); + }); + + it('passes the unedited name to the editor', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = await openEditor(segment.id); + + expect(editor(wrapper).props('original')).toBe('Tumor'); + }); +}); + +// Cine annotations use registry identities without requiring voxel storage. +describe('flat segment list on a cine image', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + seatCineImage('cine-1'); + await viewImage('cine-1'); + }); + + it('creates and edits distinct measurement segments without allocating masks', async () => { + const wrapper = mountList(); + const rulers = useRulerStore(); + const placed = []; + for (const [name, color] of [ + ['Long axis', '#ff0000'], + ['Short axis', '#0000ff'], + ]) { + await wrapper.get('.create-row').trigger('click'); + const segmentId = segments().selectedSegmentId.value!; + await rowButton(wrapper, segmentId, ['mdi-pencil']).trigger('click'); + editor(wrapper).vm.$emit('update:name', name); + editor(wrapper).vm.$emit('update:color', color); + editor(wrapper).vm.$emit('done'); + await nextTick(); + const id = rulers.addTool({ + imageID: 'cine-1', + frame: 1, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + placing: true, + }); + rulers.placeTool(id); + placed.push(id); + } + + expect(segments().segmentList.value).toHaveLength(2); + expect(placed.map((id) => rulers.appearanceOfTool(id).name)).toEqual([ + 'Long axis', + 'Short axis', + ]); + expect( + placed.map((id) => [...rulers.appearanceOfTool(id).color].slice(0, 3)) + ).toEqual([ + [255, 0, 0], + [0, 0, 255], + ]); + expect( + new Set(placed.map((id) => rulers.toolByID[id].segmentId)).size + ).toBe(2); + expect(store().getSegmentationForImage('cine-1')).toBeUndefined(); + expect(boundMasks()).toEqual([]); + expect( + wrapper.get('[data-testid="save-segments-button"]').attributes('disabled') + ).toBeDefined(); + }); + + it.each([[1], [0, 1]])( + 'reveals an occupied cine frame for annotations on frames %j', + async (...frames) => { + const segmentId = segments().addSegment({ name: 'Measurement' }); + const rulers = useRulerStore(); + const ids = frames.map((frame) => + rulers.addTool({ + imageID: 'cine-1', + segmentId, + frame, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }) + ); + const playback = useCinePlaybackStore(); + const viewId = useViewStore().activeView!; + playback.updateConfig(viewId, 'cine-1', { + frame: frames[0] === 0 ? 1 : 0, + }); + const camera = useViewCameraStore(); + const pose = { + position: [4, 6, 10] as [number, number, number], + focalPoint: [4, 6, 0] as [number, number, number], + parallelScale: 25, + }; + camera.updateConfig(viewId, 'cine-1', pose); + const wrapper = mountList(); + + expect( + revealButton(wrapper, segmentId).attributes('disabled') + ).toBeUndefined(); + await revealButton(wrapper, segmentId).trigger('click'); + + expect(playback.getConfig(viewId, 'cine-1').frame).toBe(frames[0]); + expect(camera.getConfig(viewId, 'cine-1')).toMatchObject(pose); + // The shape action retains the same temporal navigation semantics. + rulers.jumpToTool(ids[ids.length - 1]); + expect(playback.getConfig(viewId, 'cine-1').frame).toBe( + frames[frames.length - 1] + ); + } + ); + + it('keeps an empty segment reveal disabled and leaves the frame alone', async () => { + const segmentId = segments().addSegment(); + const wrapper = mountList(); + const viewId = useViewStore().activeView!; + useCinePlaybackStore().updateConfig(viewId, 'cine-1', { frame: 1 }); + expect( + revealButton(wrapper, segmentId).attributes('disabled') + ).toBeDefined(); + await revealButton(wrapper, segmentId).trigger('click'); + expect(useCinePlaybackStore().getConfig(viewId, 'cine-1').frame).toBe(1); + }); +}); + +// The segmentation display section owns the multipliers that scale every +// segment at once, and the outline thickness they share. +describe('segmentation display section', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + const slider = (wrapper: VueWrapper, label: string) => { + const found = wrapper + .findAllComponents(SliderStub) + .find((candidate) => candidate.props('label') === label); + if (!found) throw new Error(`No "${label}" slider`); + return found; + }; + + const setSlider = async ( + wrapper: VueWrapper, + label: string, + value: number + ) => { + slider(wrapper, label).vm.$emit('update:modelValue', value); + await nextTick(); + }; + + it('places display controls before the segment list', () => { + const wrapper = mountList(); + const sectionOrder = wrapper + .findAll('[data-testid$="-section"]') + .map((section) => section.attributes('data-testid')); + + expect(sectionOrder.indexOf('segment-display-section')).toBeLessThan( + sectionOrder.indexOf('segments-section') + ); + }); + + it('offers the default display controls before the image has a segmentation', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( + String(DEFAULT_SEGMENTATION_FILL_OPACITY) + ); + expect(slider(wrapper, 'Outline Opacity').attributes('data-value')).toBe( + '1' + ); + expect(slider(wrapper, 'Outline Thickness').attributes('data-value')).toBe( + '2' + ); + }); + + it('creates display state when a default control is changed', async () => { + const wrapper = mountList(); + + await setSlider(wrapper, 'Fill Opacity', 0.25); + + expect(store().getSegmentationForImage('img-1')?.fillOpacity).toBe(0.25); + }); + + it('seats each control at the segmentation’s current value', async () => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask( + segmentation.id, + segments().mintSegment({ name: 'Tumor' }) + ); + store().updateSegmentationDisplay(segmentation.id, { + fillOpacity: 0.4, + outlineOpacity: 0.6, + outlineThickness: 5, + }); + + const wrapper = mountList(); + await nextTick(); + + expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( + '0.4' + ); + expect(slider(wrapper, 'Outline Opacity').attributes('data-value')).toBe( + '0.6' + ); + expect(slider(wrapper, 'Outline Thickness').attributes('data-value')).toBe( + '5' + ); + }); + + it.each([ + ['Fill Opacity', 'fillOpacity', 0.25], + ['Outline Opacity', 'outlineOpacity', 0.5], + ['Outline Thickness', 'outlineThickness', 4], + ] as const)( + 'writes %s onto the viewed image’s segmentation', + async (label, key, value) => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask( + segmentation.id, + segments().mintSegment({ name: 'Tumor' }) + ); + const wrapper = mountList(); + await nextTick(); + + await setSlider(wrapper, label, value); + + expect(store().getSegmentationForImage('img-1')![key]).toBe(value); + } + ); + + it('writes only the viewed image’s segmentation', async () => { + await seatImage('img-2', 'MR'); + const first = store().ensureSegmentationForImage('img-1'); + store().createMask(first.id, segments().mintSegment({ name: 'Tumor' })); + const second = store().ensureSegmentationForImage('img-2'); + store().createMask(second.id, segments().mintSegment({ name: 'Node' })); + const wrapper = mountList(); + await nextTick(); + + await setSlider(wrapper, 'Fill Opacity', 0.25); + + expect(store().getSegmentationForImage('img-1')!.fillOpacity).toBe(0.25); + expect(store().getSegmentationForImage('img-2')!.fillOpacity).toBe( + DEFAULT_SEGMENTATION_FILL_OPACITY + ); + }); +}); + +// Reveal Slice is the only row control that reads the viewed image's storage, +// so it is the one that has to say when this image holds nothing for the row. +describe('Reveal Slice on a segment row', () => { + const REVEAL_DIMENSIONS = [4, 4, 8] as const; + + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1', 'CT', REVEAL_DIMENSIONS); + await viewImage('img-1'); + }); + + const viewFor = (orientation: string) => { + const view = useViewStore() + .getAllViews() + .find( + (candidate) => + candidate.type === '2D' && + candidate.options.orientation === orientation + ); + if (!view) throw new Error(`No ${orientation} view`); + return view; + }; + + const sliceOn = (orientation: string) => + useViewSliceStore().getConfig(viewFor(orientation).id, 'img-1')!.slice; + + const setSliceOn = (orientation: string, slice: number) => + useViewSliceStore().updateConfig(viewFor(orientation).id, 'img-1', { + slice, + }); + + // Paint grows the allocation with padding and clips it to the volume, so the + // binding's extent is wider than what is marked and its middle is not the + // segment's. Marking through that same path is what keeps the reveal honest. + const STROKE_PADDING = 16; + + const paintVoxel = (maskId: string, index: Index3) => { + const voxels = store().maskVoxels(maskId); + voxels.materialize(); + const labelValue = SEGMENT_VALUE; + const [i, j, k] = index; + voxels.ensureContains([i, i, j, j, k, k], STROKE_PADDING); + const { extent } = voxels.binding()!; + const [mi, mj] = extentSize(extent); + voxels.scalars()[maskOffset({ extent, mi, mj }, i, j, k)] = labelValue; + voxels.image().modified(); + }; + + it('is offered disabled, saying why, on a row this image stores nothing for', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mountList(); + await nextTick(); + + expect( + revealButton(wrapper, segment.id).attributes('disabled') + ).toBeDefined(); + }); + + it('says on the disabled control that this image holds nothing for the row', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mount(SegmentList, { + global: { + stubs: { + ...globalOptions.stubs, + VTooltip: { template: '' }, + }, + }, + }); + await nextTick(); + + expect( + revealButton(wrapper, segment.id).element.parentElement?.textContent + ).toMatch(/nothing on this image/i); + }); + + it('puts each 2D view on the middle of what the segment marks here', async () => { + const segment = makeMask('img-1', 'Tumor'); + paintVoxel(segment.maskId, [1, 1, 6]); + const wrapper = mountList(); + await nextTick(); + + // The padded allocation spans the whole volume, so its own middle is the + // slice each view already shows. + expect(sliceOn('Axial')).toBe(4); + expect(sliceOn('Sagittal')).toBe(2); + + await revealButton(wrapper, segment.id).trigger('click'); + + expect(sliceOn('Axial')).toBe(6); + expect(sliceOn('Sagittal')).toBe(1); + expect(sliceOn('Coronal')).toBe(1); + }); + + it('moves outward from the center to the nearest occupied slice', async () => { + const segment = makeMask('img-1', 'Tumor'); + paintVoxel(segment.maskId, [1, 1, 1]); + paintVoxel(segment.maskId, [1, 1, 5]); + const wrapper = mountList(); + await nextTick(); + + await revealButton(wrapper, segment.id).trigger('click'); + + expect(sliceOn('Axial')).toBe(1); + }); + + it('enables reveal when painting creates storage after the list mounts', async () => { + const segment = makeMask('img-1', 'Tumor'); + const wrapper = mountList(); + await nextTick(); + expect( + revealButton(wrapper, segment.id).attributes('disabled') + ).toBeDefined(); + + paintVoxel(segment.maskId, [1, 1, 1]); + await nextTick(); + expect( + revealButton(wrapper, segment.id).attributes('disabled') + ).toBeUndefined(); + }); + + it('leaves the views where they are when the mask marks nothing', async () => { + const segment = makeMask('img-1', 'Tumor'); + paintVoxel(segment.maskId, [1, 1, 6]); + const voxels = store().maskVoxels(segment.maskId); + voxels.scalars().fill(0); + voxels.image().modified(); + const wrapper = mountList(); + await nextTick(); + setSliceOn('Axial', 7); + + await revealButton(wrapper, segment.id).trigger('click'); + + expect(sliceOn('Axial')).toBe(7); + }); +}); + +const ANNOTATION_STORES = [ + ['ruler', useRulerStore], + ['rectangle', useRectangleStore], + ['polygon', usePolygonStore], +] as const; + +describe.each(ANNOTATION_STORES)( + 'shared segment visibility for a %s', + (_name, useStore) => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + it('composes row and global visibility with independent child flags across images and cine frames', async () => { + const tools = useStore(); + const segmentId = segments().addSegment(); + const addShape = (imageID: string, hidden = false, frame?: number) => + tools.addTool({ + imageID, + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + hidden, + frame, + }); + const shown = addShape('img-1'); + const hidden = addShape('img-1', true); + seatCineImage('cine-1'); + const cineFirst = addShape('cine-1', false, 0); + const cineSecond = addShape('cine-1', false, 1); + const viewFrame = ref(); + const rendered = useCurrentTools(tools, ref('Axial'), ref([]), viewFrame); + const ids = () => rendered.value.map((tool) => tool.id); + const wrapper = mountList(); + expect(ids()).toEqual([shown]); + + await rowButton(wrapper, segmentId, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + expect(ids()).toEqual([]); + await viewImage('cine-1'); + viewFrame.value = 0; + expect(ids()).toEqual([]); + await wrapper + .get('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + expect(ids()).toEqual([cineFirst]); + viewFrame.value = 1; + expect(ids()).toEqual([cineSecond]); + await wrapper + .get('[data-testid="toggle-segments-visible-button"]') + .trigger('click'); + expect(ids()).toEqual([]); + await rowButton(wrapper, segmentId, ['mdi-eye', 'mdi-eye-off']).trigger( + 'click' + ); + await viewImage('img-1'); + viewFrame.value = undefined; + expect(ids()).toEqual([shown]); + expect(tools.toolByID[hidden].hidden).toBe(true); + }); + + it('keeps the active placement alive through hiding, committing and starting again', () => { + const tools = useStore(); + const segmentId = segments().addSegment(); + const metadata = ref({ + imageID: 'img-1', + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + const placing = usePlacingAnnotationTool(tools, metadata); + placing.add(); + const first = placing.id.value!; + const whitelist = ref([first]); + const rendered = useCurrentTools(tools, ref('Axial'), whitelist); + const otherViewStub = tools.addTool({ ...metadata.value, placing: true }); + placing.beginPlacement(); + segments().updateSegment(segmentId, { visible: false }); + expect(rendered.value.map((tool) => tool.id)).toEqual([first]); + expect(tools.toolByID[otherViewStub]).toBeDefined(); + + placing.commit(); + expect(rendered.value).toEqual([]); + expect(tools.toolByID[first].placing).toBe(false); + placing.add(); + whitelist.value = [placing.id.value!]; + expect(rendered.value.map((tool) => tool.id)).toEqual([placing.id.value]); + segments().updateSegment(segmentId, { visible: true }); + expect(rendered.value.map((tool) => tool.id)).toEqual([ + first, + placing.id.value, + ]); + placing.remove(); + expect(rendered.value.map((tool) => tool.id)).toEqual([first]); + }); + } +); + +describe('locked segment editor routes', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + const protectedContent = () => { + const segmentId = segments().addSegment({ name: 'Tumor' }); + const rulers = useRulerStore(); + const content = ['img-1', 'img-2'].map((imageID) => { + const mask = maskOn(imageID, segmentId); + seedVoxel(mask.id, [1, 1, 0]); + const ruler = rulers.addTool({ + imageID, + segmentId, + slice: 0, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + return { imageID, maskId: mask.id, ruler }; + }); + const expectPreserved = () => { + expect(segments().appearanceOf(segmentId).name).toBe('Tumor'); + content.forEach(({ imageID, maskId, ruler }) => { + expect(store().maskFor(imageID, segmentId)?.id).toBe(maskId); + expect(markedVoxels(maskId)).toEqual([[1, 1, 0, SEGMENT_VALUE]]); + expect(rulers.toolByID[ruler].segmentId).toBe(segmentId); + }); + }; + return { segmentId, expectPreserved }; + }; + + it('disables color, edit and delete consistently and restores editing after unlocking', async () => { + const { segmentId, expectPreserved } = protectedContent(); + segments().updateSegment(segmentId, { locked: true }); + const wrapper = mountList(); + for (const action of [ + 'segment-color-button', + 'edit-segment-button', + 'delete-segment-button', + ]) { + const button = wrapper.get( + `[data-id="${segmentId}"] [data-testid="${action}"]` + ); + expect(button.attributes('disabled')).toBeDefined(); + await button.trigger('click'); + expect(editor(wrapper).exists()).toBe(false); + expectPreserved(); + } + segments().updateSegment(segmentId, { locked: false }); + await nextTick(); + await wrapper.get('[data-testid="segment-color-button"]').trigger('click'); + expect(editor(wrapper).exists()).toBe(true); + editor(wrapper).vm.$emit('update:name', 'Lesion'); + editor(wrapper).vm.$emit('done'); + await nextTick(); + expect(segments().appearanceOf(segmentId).name).toBe('Lesion'); + }); + + it.each(['done', 'delete'])( + 'refuses %s if the segment becomes locked while its editor is open', + async (action) => { + const { segmentId, expectPreserved } = protectedContent(); + const wrapper = mountList(); + await wrapper + .get('[data-testid="segment-color-button"]') + .trigger('click'); + editor(wrapper).vm.$emit('update:name', 'Changed'); + await nextTick(); + segments().updateSegment(segmentId, { locked: true }); + await nextTick(); + expect(editor(wrapper).props('locked')).toBe(true); + editor(wrapper).vm.$emit(action); + await nextTick(); + expectPreserved(); + } + ); +}); + +// --------------------------------------------------------------------------- +// Deleting a segment cascades to its mask on every image and to every +// annotation naming it, none of which need be visible here, and there is no +// undo. No dialog asks first, as everywhere else in the app, so the list says +// afterwards what went — the way removeSelectedTools does. +// --------------------------------------------------------------------------- + +describe('deleting a segment says what went with it', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); + }); + + const titles = () => + useMessageStore().messages.map((message) => message.title); + + /** A segment with a mask and a ruler on each of the given images. */ + 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'); + 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', + ]); + }); + + it('stays quiet when the segment held nothing', async () => { + const empty = makeSegment('Empty'); + const wrapper = mountList(); + await nextTick(); + + await deleteRow(wrapper, empty); + + expect(segments().getSegment(empty)).toBeUndefined(); + expect(titles()).toEqual([]); + }); + + it('reports the same cascade when the editor deletes', async () => { + const segmentId = spreadSegment(['img-1', 'img-2']); + const wrapper = mountList(); + await nextTick(); + await wrapper + .get(`[data-id="${segmentId}"] [data-testid="segment-color-button"]`) + .trigger('click'); + + editor(wrapper).vm.$emit('delete'); + await nextTick(); + + expect(segments().getSegment(segmentId)).toBeUndefined(); + expect(titles()).toEqual(['Deleted 2 masks on 2 images and 2 annotations']); + }); +}); + +// --------------------------------------------------------------------------- +// A row is rebuilt from every annotation in the scene, and dragging one ruler +// is a store write per pointer move. The list hands back the row object it +// built last time when nothing the row shows has changed, so the item list's +// per-row memo holds and only the rows that changed re-render. +// --------------------------------------------------------------------------- + +describe('segment row identity', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1'); + await viewImage('img-1'); + }); + + const rulerOn = (segmentId: string, slice = 0) => + useRulerStore().addTool({ + imageID: 'img-1', + segmentId, + slice, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + + const rowsOf = (wrapper: VueWrapper) => + itemList(wrapper).props('items') as Array<{ id: string }>; + + it('keeps every row when an annotation moves', async () => { + const first = makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const ruler = rulerOn(first.segmentId); + const wrapper = mountList(); + await nextTick(); + const before = rowsOf(wrapper); + + useRulerStore().updateTool(ruler, { slice: 1 }); + await nextTick(); + + const after = rowsOf(wrapper); + expect(useRulerStore().toolByID[ruler].slice).toBe(1); + expect(after[0]).toBe(before[0]); + expect(after[1]).toBe(before[1]); + expect(after.map((row) => row.id)).toEqual([first.id, second.id]); + }); + + it('replaces only the row whose annotation count changed', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + const before = rowsOf(wrapper); + + rulerOn(second.segmentId); + await nextTick(); + + const after = rowsOf(wrapper); + expect(after[0]).toBe(before[0]); + expect(after[1]).not.toBe(before[1]); + }); + + it('replaces only the row whose own fields changed', async () => { + makeMask('img-1', 'Tumor'); + const second = makeMask('img-1', 'Node'); + const wrapper = mountList(); + await nextTick(); + const before = rowsOf(wrapper); + + segments().updateSegment(second.segmentId, { name: 'Lesion' }); + await nextTick(); + + const after = rowsOf(wrapper); + expect(after[0]).toBe(before[0]); + expect(after[1]).not.toBe(before[1]); + }); + + it('still offers reveal for a segment that only has annotations', async () => { + const shaped = makeSegment('Shaped'); + rulerOn(shaped); + const wrapper = mountList(); + await nextTick(); + + expect( + revealButton(wrapper, shaped).attributes('disabled') + ).toBeUndefined(); + }); +}); diff --git a/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts b/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts new file mode 100644 index 000000000..d04fac2af --- /dev/null +++ b/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts @@ -0,0 +1,308 @@ +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, +} 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. +// +// Phase 2's exit condition is on user-visible text: no panel says "segment +// group", "labelmap", "label value" or "layer". 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); + +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 () => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask(segmentation.id, mintSegment({ name: 'Tumor' })); + const wrapper = mountList(); + await nextTick(); + + expect( + wrapper.findAll('[data-testid="save-segments-button"]') + ).toHaveLength(1); + }); + + it('opens the save dialog on the viewed image segmentation', async () => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask(segmentation.id, mintSegment({ name: 'Tumor' })); + const wrapper = mountList(); + await nextTick(); + + expect(saveDialog(wrapper).exists()).toBe(false); + expect(saveButton(wrapper).exists()).toBe(true); + + await saveButton(wrapper).trigger('click'); + await nextTick(); + + expect(saveDialog(wrapper).props('id')).toBe(segmentation.id); + }); + + // The create affordance names the row it adds, and it reads as an expression + // rather than a literal attribute, so the source scan below cannot see it. + it('names what the create affordance adds without a storage word', async () => { + const wrapper = mountList(); + await nextTick(); + + expect(wrapper.findComponent(ItemListStub).props('createText')).toBe( + 'New segment' + ); + }); + + it('follows the viewed image rather than the selected type', async () => { + const first = store().ensureSegmentationForImage('img-1'); + store().createMask(first.id, mintSegment({ name: 'Tumor' })); + const second = store().ensureSegmentationForImage('img-2'); + const onSecond = store().createMask( + second.id, + mintSegment({ name: 'Node' }) + ); + // The selected type has its mask on the image that is NOT being viewed. + useSegmentStore().segments.selectSegment(onSecond.segmentId); + const wrapper = mountList(); + await nextTick(); + + expect(saveButton(wrapper).exists()).toBe(true); + await saveButton(wrapper).trigger('click'); + await nextTick(); + + expect(saveDialog(wrapper).props('id')).toBe(first.id); + }); +}); + +// --- Phase 2 exit condition: 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'); + +/** Attributes Vuetify and plain HTML render as text the user reads. */ +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)); + +/** The segmentation panel: the tab, the list, the editors, the save dialog. */ +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', () => { + // The two the phase is built around must be present, so the scan is never + // vacuous; a renamed save dialog simply drops out of the list. + expect(exists('src/components/AnnotationsModule.vue')).toBe(true); + expect(exists('src/segmentation/components/SegmentList.vue')).toBe(true); + + const banned = /segment group|labelmap|label value|layer/i; + const hits = SEGMENTATION_PANEL.filter(exists).flatMap((rel) => + bannedIn(rel, banned).map((line) => `${rel}: ${line}`) + ); + + expect(hits).toEqual([]); + }); + + it('keeps the panel’s notification titles free of storage words', () => { + const files = SEGMENTATION_PANEL.filter(exists); + // The panel reports at least one failure to the user, so the scan reads + // something rather than passing on an empty match set. + const messages = files.flatMap((rel) => bannedMessagesIn(rel, /.*/)); + expect(messages.length).toBeGreaterThan(0); + + const banned = /segment group|labelmap|label value/i; + const hits = files.flatMap((rel) => + bannedMessagesIn(rel, banned).map((message) => `${rel}: ${message}`) + ); + + expect(hits).toEqual([]); + }); + + it('says "segment group" nowhere a user can read it', () => { + // "Layer" is a separate VolView feature and keeps its name; the storage + // words do not survive anywhere in the component tree. + const banned = /segment group|labelmap|label value/i; + const hits = [ + ...componentFiles('src/components'), + ...componentFiles('src/segmentation'), + ].flatMap((rel) => bannedIn(rel, banned).map((line) => `${rel}: ${line}`)); + + expect(hits).toEqual([]); + }); +}); diff --git a/src/segmentation/composables/deleteSegment.ts b/src/segmentation/composables/deleteSegment.ts new file mode 100644 index 000000000..ba49ba4af --- /dev/null +++ b/src/segmentation/composables/deleteSegment.ts @@ -0,0 +1,51 @@ +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. + */ +function countCascade(segmentId: string) { + const images = Object.values(useSegmentationStore().segmentations).flatMap( + (segmentation) => + Object.values(segmentation.masks) + .filter((mask) => mask.segmentId === segmentId) + .map(() => segmentation.parentImageId) + ); + const annotations = Object.values(AnnotationToolStoreMap).reduce( + (total, useStore) => + total + + useStore().finishedTools.filter((tool) => tool.segmentId === segmentId) + .length, + 0 + ); + return { masks: images.length, images: new Set(images).size, annotations }; +} + +/** + * Deletes a segment and says what went with it. The cascade reaches masks on + * images this one is not viewing and annotations on other slices and axes, so + * its scope is invisible from here and there is no undo: the same reason + * `removeSelectedTools` reports its count. No dialog asks first, which is what + * the rest of the app does. + */ +export function deleteSegmentAndReport( + registry: SegmentRegistry, + segmentId: string +) { + if (!registry.getSegment(segmentId)) return; + const { masks, images, annotations } = countCascade(segmentId); + registry.deleteSegment(segmentId); + + const removed = [ + masks > 0 && + `${masks} ${plural(masks, 'mask')} on ${images} ${plural(images, 'image')}`, + annotations > 0 && `${annotations} ${plural(annotations, 'annotation')}`, + ].filter((part): part is string => !!part); + if (removed.length > 0) + useMessageStore().addInfo(`Deleted ${removed.join(' and ')}`); +} diff --git a/src/segmentation/composables/useMaskRevision.ts b/src/segmentation/composables/useMaskRevision.ts new file mode 100644 index 000000000..ba0a4db3b --- /dev/null +++ b/src/segmentation/composables/useMaskRevision.ts @@ -0,0 +1,35 @@ +import { ref, watchEffect } from 'vue'; + +import { useSegmentationStore } from '@/src/segmentation/store'; +import { listMasks } from '@/src/segmentation/model'; + +/** + * A counter every mask change bumps, voxel writes included. A mask's extent is + * reactive but a write inside the box it already has moves nothing, so this is + * the only trace of one a consumer can watch. It says something changed and + * nothing about what. A stroke bumps it once per changed mask per sample, + * so debounce anything expensive that reads it. + * + * Scoped to the caller: the masks are watched only while it is alive. + */ +export function useMaskRevision() { + const segmentationStore = useSegmentationStore(); + const revision = ref(0); + + // Re-taken whenever a mask is seated or dropped. Every writer already + // announces itself to vtk, so watching the mask itself catches the ones that + // reach the buffer without going through the store. + watchEffect((onCleanup) => { + const subscriptions = Object.values(segmentationStore.segmentations) + .flatMap((segmentation) => listMasks(segmentation)) + .flatMap((segment) => segment.representations.labelmap ?? []) + .map((binding) => + binding.image.onModified(() => { + revision.value += 1; + }) + ); + onCleanup(() => subscriptions.forEach((entry) => entry.unsubscribe())); + }); + + return revision; +} diff --git a/src/segmentation/composables/usePaintInteractionMode.ts b/src/segmentation/composables/usePaintInteractionMode.ts new file mode 100644 index 000000000..9b1cd7345 --- /dev/null +++ b/src/segmentation/composables/usePaintInteractionMode.ts @@ -0,0 +1,14 @@ +import { computed } from 'vue'; +import { PaintMode } from '@/src/core/tools/paint'; +import { usePaintToolStore } from '@/src/store/tools/paint'; +import { useActionHeld } from '@/src/composables/useKeyboardShortcuts'; + +export function usePaintInteractionMode() { + const paint = usePaintToolStore(); + const held = useActionHeld('paintEyedropper'); + return computed(() => + paint.isActive && paint.isPaintingModeActive && held.value + ? PaintMode.Eyedropper + : paint.activePaintMode + ); +} diff --git a/src/segmentation/composables/useSegmentEditing.ts b/src/segmentation/composables/useSegmentEditing.ts new file mode 100644 index 000000000..66947b256 --- /dev/null +++ b/src/segmentation/composables/useSegmentEditing.ts @@ -0,0 +1,100 @@ +import { computed, reactive, ref } from 'vue'; + +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import type { Maybe } from '@/src/types'; +import { cssColorToRGBA } from '@/src/segmentation/color'; +import { deleteSegmentAndReport } from '@/src/segmentation/composables/deleteSegment'; + +/** + * The edit dialog both pickers open: one editor, one set of fields, one place + * that decides what a name may be. Reads every field through the registry's + * resolver, so an unset one shows the app default. + */ +export function useSegmentEditing(registry: () => SegmentRegistry) { + const editingSegmentId = ref>(undefined); + const editDialog = ref(false); + const editState = reactive({ + name: '', + color: '', + fillOpacity: 1, + outlineOpacity: 1, + strokeWidth: 1, + }); + + const editingSegment = computed(() => + editingSegmentId.value + ? registry().getSegment(editingSegmentId.value) + : undefined + ); + + const editingName = computed( + () => registry().appearanceOf(editingSegmentId.value).name + ); + + const invalidNames = computed( + () => + new Set( + registry() + .segmentList.value.filter( + (type) => type.id !== editingSegmentId.value + ) + .map((type) => registry().appearanceOf(type.id).name.trim()) + ) + ); + + function startEditing(id: string) { + if (!registry().getSegment(id) || registry().appearanceOf(id).locked) + return; + const appearance = registry().appearanceOf(id); + editingSegmentId.value = id; + editDialog.value = true; + editState.name = appearance.name; + editState.color = appearance.cssColor; + editState.fillOpacity = appearance.fillOpacity; + editState.outlineOpacity = appearance.outlineOpacity; + editState.strokeWidth = appearance.strokeWidth; + } + + function stopEditing(commit: boolean) { + const id = editingSegmentId.value; + if ( + id && + commit && + registry().getSegment(id) && + !registry().appearanceOf(id).locked + ) { + registry().updateSegment(id, { + name: editState.name, + color: cssColorToRGBA(editState.color), + fillOpacity: editState.fillOpacity, + outlineOpacity: editState.outlineOpacity, + strokeWidth: editState.strokeWidth, + }); + } + editingSegmentId.value = undefined; + editDialog.value = false; + } + + // Deleting a segment takes its masks on every image and its shapes with it. + function deleteEditingSegment() { + const id = editingSegmentId.value; + if (id && !registry().appearanceOf(id).locked) + deleteSegmentAndReport(registry(), id); + stopEditing(false); + } + + return { + editingSegmentId, + editDialog, + editState, + editingSegment, + editingName, + editingLocked: computed( + () => registry().appearanceOf(editingSegmentId.value).locked + ), + invalidNames, + startEditing, + stopEditing, + deleteEditingSegment, + }; +} diff --git a/src/segmentation/composables/useSegmentRevealPulse.ts b/src/segmentation/composables/useSegmentRevealPulse.ts new file mode 100644 index 000000000..047dfee28 --- /dev/null +++ b/src/segmentation/composables/useSegmentRevealPulse.ts @@ -0,0 +1,31 @@ +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); + +/** Briefly rises from the normal display to a highlight and settles back. */ +export function pulseSegmentMask(maskId: string) { + const previous = animationByMaskId.get(maskId); + if (previous != null) cancelAnimationFrame(previous); + + const started = performance.now(); + const duration = 3000; + const pulsePeriod = 500; + const animate = (now: number) => { + const elapsed = now - started; + if (elapsed < duration) { + pulseByMaskId[maskId] = Math.abs( + Math.sin((Math.PI * elapsed) / pulsePeriod) + ); + animationByMaskId.set(maskId, requestAnimationFrame(animate)); + return; + } + delete pulseByMaskId[maskId]; + animationByMaskId.delete(maskId); + }; + + animationByMaskId.set(maskId, requestAnimationFrame(animate)); +} diff --git a/src/segmentation/composables/useSegmentShapes.ts b/src/segmentation/composables/useSegmentShapes.ts new file mode 100644 index 000000000..7ba5b291e --- /dev/null +++ b/src/segmentation/composables/useSegmentShapes.ts @@ -0,0 +1,94 @@ +import { computed } from 'vue'; + +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { useAnnotationToolStore } from '@/src/store/tools'; +import { AnnotationToolType } from '@/src/store/tools/types'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { frameOfReferenceToImageSliceAndAxis } from '@/src/utils/frameOfReference'; +import type { AnnotationTool } from '@/src/types/annotation-tool'; + +const SHAPE_TOOLS = [ + { type: AnnotationToolType.Ruler, icon: 'mdi-ruler' }, + { type: AnnotationToolType.Rectangle, icon: 'mdi-vector-square' }, + { type: AnnotationToolType.Polygon, icon: 'mdi-pentagon-outline' }, +]; + +/** Where an annotation sits: a cine frame, or a slice on one image axis. */ +const placement = (tool: AnnotationTool & { axis: string }) => + tool.frame != null + ? `Frame ${tool.frame + 1}` + : `${tool.axis} ${tool.slice + 1}`; + +/** + * The shapes drawn on the viewed image, grouped by the segment each one names. + * A segment's row lists these under it, so the sidebar holds no second list of + * the same annotations. + */ +export function useSegmentShapes() { + const { currentImageID, currentImageMetadata } = useCurrentImage(); + + const shapes = computed(() => + SHAPE_TOOLS.flatMap(({ type, icon }) => { + const store = useAnnotationToolStore(type); + const rulers = useRulerStore(); + return store.finishedTools + .filter((tool) => tool.imageID === currentImageID.value) + .map((tool) => { + const { axis } = frameOfReferenceToImageSliceAndAxis( + tool.frameOfReference, + currentImageMetadata.value, + { allowOutOfBoundsSlice: true } + ) ?? { axis: 'unknown' }; + const located = { ...tool, axis }; + return { + id: tool.id, + type, + icon, + segmentId: tool.segmentId, + hidden: !!tool.hidden, + axis, + slice: tool.slice, + frame: tool.frame, + placement: placement(located), + // Only a ruler carries a number a user reads off the list. + measurement: + type === AnnotationToolType.Ruler + ? `${rulers.lengthByID[tool.id].toFixed(2)}mm` + : '', + jumpTo: () => store.jumpToTool(tool.id), + remove: () => store.removeTool(tool.id), + toggleHidden: () => + store.updateTool(tool.id, { + hidden: !store.toolByID[tool.id].hidden, + }), + setHidden: (hidden: boolean) => + store.updateTool(tool.id, { hidden }), + assignSegment: (segmentId: string) => + store.updateTool(tool.id, { segmentId }), + }; + }); + }) + ); + + // Grouped once so a list of segments costs one pass over the shapes rather + // than one pass per segment. + const shapesBySegment = computed(() => { + const bySegment = new Map(); + shapes.value.forEach((shape) => { + if (!shape.segmentId) return; + const group = bySegment.get(shape.segmentId); + if (group) group.push(shape); + else bySegment.set(shape.segmentId, [shape]); + }); + return bySegment; + }); + + const shapesOf = (segmentId: string) => + shapesBySegment.value.get(segmentId) ?? []; + + return { shapes, shapesOf }; +} + +export type SegmentShape = ReturnType< + typeof useSegmentShapes +>['shapes']['value'][number]; diff --git a/src/segmentation/editing/__tests__/processWorker.spec.ts b/src/segmentation/editing/__tests__/processWorker.spec.ts new file mode 100644 index 000000000..95e1a4abd --- /dev/null +++ b/src/segmentation/editing/__tests__/processWorker.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { terminateProcessWorkers } from '@/src/segmentation/editing/processWorker'; +import { hostOverSilentWorkers } from '@/src/segmentation/editing/__tests__/silentWorker'; + +// --------------------------------------------------------------------------- +// A process worker that dies, and one a cancelled run walks away from. Comlink +// answers a call only when the worker posts a reply, so a worker that fails to +// load its module chunk, or that the browser kills, leaves the call waiting +// forever: the process stays in `computing`, nothing is rolled back, and the +// cached instance poisons every later run. A job already posted cannot be +// called back either, so a cancelled run's work would keep the worker busy. +// --------------------------------------------------------------------------- + +describe('a process worker host', () => { + it('reuses one worker across calls', async () => { + const { host, workers } = hostOverSilentWorkers(); + + // Discarded results get a catch: a call the host later ends rejects, and + // a promise nobody holds would report that as unhandled. + host.call((api) => api.smooth(1)).catch(() => undefined); + host.call((api) => api.smooth(2)).catch(() => undefined); + + expect(workers).toHaveLength(1); + // Both calls reached the same endpoint rather than being dropped. + await Promise.resolve(); + expect(workers[0].posted.length).toBeGreaterThanOrEqual(2); + }); + + it('rejects the calls in flight when the worker errors', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const first = host.call((api) => api.smooth(1)); + const second = host.call((api) => api.smooth(2)); + workers[0].emit({ type: 'error', message: 'Failed to load worker chunk' }); + + await expect(first).rejects.toThrow('Failed to load worker chunk'); + await expect(second).rejects.toThrow('Failed to load worker chunk'); + }); + + it('names the event when the failure carries no message', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const call = host.call((api) => api.smooth(1)); + workers[0].emit({ type: 'messageerror' }); + + await expect(call).rejects.toThrow(/messageerror/); + }); + + it('starts a fresh worker for the call after a failure', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const call = host.call((api) => api.smooth(1)); + workers[0].emit({ type: 'error', message: 'worker gone' }); + await expect(call).rejects.toThrow('worker gone'); + + host.call((api) => api.smooth(2)).catch(() => undefined); + + // The dead instance is dropped, so the next run is not answered by it. + expect(workers).toHaveLength(2); + }); + + it('ends the calls in flight when the host is terminated', async () => { + const { host, workers } = hostOverSilentWorkers(); + + const call = host.call((api) => api.smooth(1)); + host.terminate(); + + await expect(call).rejects.toThrow(/stopped/); + expect(workers[0].terminated).toBe(true); + }); + + it('starts a fresh worker for the run after a terminate', async () => { + const { host, workers } = hostOverSilentWorkers(); + + host.call((api) => api.smooth(1)).catch(() => undefined); + terminateProcessWorkers(); + host.call((api) => api.smooth(2)).catch(() => undefined); + + expect(workers[0].terminated).toBe(true); + expect(workers).toHaveLength(2); + expect(workers[1].terminated).toBe(false); + }); +}); diff --git a/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts b/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts new file mode 100644 index 000000000..cd93fc386 --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizePolygon.spec.ts @@ -0,0 +1,305 @@ +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, + seatImage, + seedVoxel, + store, + segmentOfMask, + type Index3, + lockSegment, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +// --------------------------------------------------------------------------- +// Rasterizing a polygon into a bounded mask. The write lives outside +// PolygonTool.vue so it can be tested: the component owns the view, not the +// voxels. The mask grows to hold the polygon before `fillPoly` runs (a mask +// that does not reach the polygon silently swallows every pixel), and the +// filled voxels are cleared in the other UNLOCKED segments of the image. A +// locked one keeps its voxels, so the two segments overlap there. +// +// Unit spacing and a zero origin make world points index points, and an +// identity direction maps the Axial view axis to K. +// --------------------------------------------------------------------------- + +const DIMENSIONS: Index3 = [6, 6, 2]; + +// fillPoly fills i in 1..4 and j in 2..4 for this square. +const SQUARE: Vector3[] = [ + [1, 1, 0], + [4, 1, 0], + [4, 4, 0], + [1, 4, 0], +]; + +const rasterize = (segmentId: string | undefined, points = SQUARE, slice = 0) => + rasterizePolygon({ + imageId: 'img-1', + segmentId, + points, + slice, + viewAxis: 'Axial', + }); + +/** What a polygon carries: the type, not the record it lands in. */ +const rasterizeInto = ( + maskId: string | undefined, + points = SQUARE, + slice = 0 +) => rasterize(maskId && segmentOfMask(maskId), points, slice); + +const segments = () => useSegmentStore().segments; + +const segmentNamesOf = (imageId: string) => + listMasks(store().getSegmentationForImage(imageId)!).map( + (segment) => segments().appearanceOf(segment.segmentId).name + ); + +describe('rasterizing a polygon into a bounded mask', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatImage('img-1', { dimensions: DIMENSIONS }); + }); + + it('grows the mask to hold the polygon and fills it', () => { + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + const labelValue = labelValueOf(maskId); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValue); + expect(maskValueAt(maskId, [3, 3, 0])).toBe(labelValue); + const extent = extentOf(maskId)!; + expect(extent[0]).toBeLessThanOrEqual(1); + expect(extent[1]).toBeGreaterThanOrEqual(4); + expect(extent[3]).toBeGreaterThanOrEqual(4); + // One slice was drawn on, so one slice is covered. + expect([extent[4], extent[5]]).toEqual([0, 0]); + }); + + it('leaves everything outside the polygon alone', () => { + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + expect(maskValueAt(maskId, [0, 0, 0])).toBeFalsy(); + expect(maskValueAt(maskId, [5, 5, 0])).toBeFalsy(); + expect(maskValueAt(maskId, [2, 3, 1])).toBeFalsy(); + }); + + it('clears the filled voxels in another segment’s mask', () => { + const neighbor = addMask('img-1', 'Neighbour'); + seedVoxel(neighbor, [2, 3, 0]); + seedVoxel(neighbor, [0, 0, 0]); + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + expect(maskValueAt(neighbor, [2, 3, 0])).toBe(0); + expect(maskValueAt(neighbor, [0, 0, 0])).toBe(labelValueOf(neighbor)); + }); + + it('shares the filled voxels with a locked neighbour', () => { + const locked = addMask('img-1', 'Locked'); + const unlocked = addMask('img-1', 'Unlocked'); + seedVoxel(locked, [2, 3, 0]); + seedVoxel(unlocked, [2, 3, 0]); + lockSegment(locked, true); + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + + expect(maskValueAt(locked, [2, 3, 0])).toBe(labelValueOf(locked)); + expect(maskValueAt(unlocked, [2, 3, 0])).toBe(0); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + }); + + it('publishes each changed neighbor once before returning from a fill', () => { + const neighbors = ['First', 'Second', 'Locked', 'Background'].map( + (name) => { + const id = addMask('img-1', name); + const voxels = store().maskVoxels(id); + voxels.materialize(); + voxels.ensureContains([0, 5, 0, 5, 0, 1]); + if (name !== 'Background') voxels.scalars().fill(1); + if (name === 'Locked') lockSegment(id, true); + const modified = vi.fn(); + voxels.image().onModified(modified); + return { id, modified }; + } + ); + const target = addMask('img-1', 'Target'); + + rasterizeInto(target); + + expect(neighbors.map(({ modified }) => modified.mock.calls.length)).toEqual( + [1, 1, 0, 0] + ); + expect(neighbors.map(({ id }) => maskValueAt(id, [2, 3, 0]))).toEqual([ + 0, 0, 1, 0, + ]); + // Repeating unchanged writes must not publish another sibling event. + rasterizeInto(target); + expect(neighbors[0].modified).toHaveBeenCalledTimes(1); + + rasterizeInto(target, SQUARE, 1); + expect(neighbors.map(({ modified }) => modified.mock.calls.length)).toEqual( + [2, 2, 0, 0] + ); + expect(maskValueAt(target, [2, 3, 1])).toBe(1); + expect(maskValueAt(neighbors[0].id, [0, 0, 0])).toBe(1); + }); + + it('creates nothing for a polygon that covers no voxel', () => { + // Resolving the target mints the record, its segmentation and its + // storage, so a polygon with nothing to fill is answered before that. + const empty = rasterize(undefined, []); + const outside = rasterize(undefined, [ + [-4, -4, 0], + [-2, -4, 0], + [-2, -2, 0], + [-4, -2, 0], + ]); + + expect(empty).toEqual({ segmentId: undefined, maskId: undefined }); + expect(outside).toEqual({ segmentId: undefined, maskId: undefined }); + expect(store().getSegmentationForImage('img-1')).toBeUndefined(); + }); + + it('consults the neighbours over the polygon, not the whole mask', () => { + const maskId = addMask('img-1', 'Tumor'); + // A mask over the whole image: its own box says nothing about where this + // polygon lands, and every neighbour touching it would be walked per + // filled pixel. + const voxels = store().maskVoxels(maskId); + voxels.materialize(); + voxels.ensureContains([0, 5, 0, 5, 0, 1]); + const voxelClaim = vi.spyOn(store(), 'voxelClaim'); + + rasterizeInto(maskId); + + expect(voxelClaim).toHaveBeenCalledTimes(1); + expect(voxelClaim.mock.calls[0][2]).toEqual([1, 4, 1, 4, 0, 0]); + }); + + it('refuses a locked segment and leaves every mask as it was', () => { + const neighbour = addMask('img-1', 'Neighbour'); + seedVoxel(neighbour, [2, 3, 0]); + const maskId = addMask('img-1', 'Tumor'); + lockSegment(maskId, true); + + const result = rasterizeInto(maskId); + + // Refused: the type is named back, but nothing was written into a record. + expect(result.maskId).toBeUndefined(); + expect(result.segmentId).toBe(segmentOfMask(maskId)); + expect(maskValueAt(maskId, [2, 3, 0])).toBeFalsy(); + // The clearer never ran, so the neighbour keeps what a fill would take. + expect(maskValueAt(neighbour, [2, 3, 0])).toBe(labelValueOf(neighbour)); + }); + + it('keeps an earlier polygon when a later one grows the mask', () => { + const maskId = addMask('img-1', 'Tumor'); + + rasterizeInto(maskId); + rasterizeInto( + maskId, + [ + [1, 1, 1], + [4, 1, 1], + [4, 4, 1], + [1, 4, 1], + ], + 1 + ); + + const labelValue = labelValueOf(maskId); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValue); + expect(maskValueAt(maskId, [2, 3, 1])).toBe(labelValue); + }); + + it('stays inside the parent image for a polygon that overhangs it', () => { + const maskId = addMask('img-1', 'Tumor'); + + expect(() => + rasterizeInto(maskId, [ + [-2, -2, 0], + [2, -2, 0], + [2, 2, 0], + [-2, 2, 0], + ]) + ).not.toThrow(); + + const extent = extentOf(maskId)!; + expect(extent[0]).toBe(0); + expect(extent[2]).toBe(0); + expect(maskValueAt(maskId, [1, 1, 0])).toBe(labelValueOf(maskId)); + }); + + it('rasterizes into a segment it resolves when the polygon carries none', () => { + const maskId = rasterize(undefined).maskId!; + + const segmentation = store().getSegmentationForImage('img-1')!; + expect(segmentation.order).toEqual([maskId]); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + }); + + it('rasterizes into the type the polygon names, not the selected one', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + const node = segments().addSegment({ name: 'Node' }); + // The user picks another type between placing the polygon and rasterizing + // it; the polygon still carries the type it was drawn with. + segments().selectSegment(node); + + const maskId = rasterize(tumor).maskId!; + + expect(segmentOfMask(maskId)).toBe(tumor); + expect(segmentNamesOf('img-1')).toEqual(['Tumor']); + expect(maskValueAt(maskId, [2, 3, 0])).toBe(labelValueOf(maskId)); + + const nextEdit = store().resolveEditTarget('img-1'); + expect(segmentOfMask(nextEdit)).toBe(node); + expect(segmentNamesOf('img-1')).toEqual(['Tumor', 'Node']); + }); + + it('rasterizes into the record its type already has here', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + + const first = rasterize(tumor); + const second = rasterize(tumor, SQUARE, 1); + + expect(second.maskId).toBe(first.maskId); + expect(segmentNamesOf('img-1')).toEqual(['Tumor']); + }); + + it('leaves the rasterized record for the next paint edit', () => { + const tumor = segments().addSegment({ name: 'Tumor' }); + + const rasterized = rasterize(tumor).maskId!; + const painted = store().resolveEditTarget('img-1'); + + expect(painted).toBe(rasterized); + expect(segments().selectedSegmentId.value).toBe(tumor); + expect(segmentNamesOf('img-1')).toEqual(['Tumor']); + }); + + it('rasterizes into the type it was given, not the selected one', () => { + const active = addMask('img-1', 'Active'); + segments().selectSegment(segmentOfMask(active)); + const named = addMask('img-1', 'Named'); + + const result = rasterizeInto(named); + + expect(result.maskId).toBe(named); + expect(maskValueAt(named, [2, 3, 0])).toBe(labelValueOf(named)); + expect(maskValueAt(active, [2, 3, 0])).toBeFalsy(); + }); +}); diff --git a/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts b/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts new file mode 100644 index 000000000..f990499c6 --- /dev/null +++ b/src/segmentation/editing/__tests__/rasterizeTarget.spec.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { + seatSpecImage as seatImage, + maskOn, + lockSegment, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +import { resolveRasterizeTarget } from '@/src/segmentation/editing/rasterizePolygon'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +const store = () => useSegmentationStore(); +const segments = () => useSegmentStore().segments; + +/** A type with this image's mask for it, which is what a polygon names. */ +const makeMask = (imageId: string, name: string) => { + const segmentId = segments().mintSegment({ name }); + return { segmentId, record: maskOn(imageId, segmentId) }; +}; + +/** The resolved target, for the cases that expect one. */ +const targetOf = (imageId: string, segmentId: string | undefined) => + resolveRasterizeTarget(imageId, segmentId)!; + +describe('polygon rasterize target', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('allocates storage for a record that has none', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + + const target = targetOf('img-1', segment.segmentId); + + expect(target.labelValue).toBe(SEGMENT_VALUE); + expect( + store() + .maskLayersForImage('img-1') + .map((layer) => layer.maskId) + ).toEqual([target.maskId]); + expect(target.voxels.image()).toBe( + store().findMaskBinding(target.maskId)!.image + ); + expect( + store().getMask(segment.record.id).representations.labelmap!.image + ).toBe(target.voxels.image()); + }); + + it('resolves the given type rather than the first one', async () => { + await seatImage('img-1'); + const first = makeMask('img-1', 'Other'); + store().maskVoxels(first.record.id).materialize(); + const second = makeMask('img-1', 'Tumor'); + + const target = targetOf('img-1', second.segmentId); + + expect(target.voxels.image()).not.toBe( + store().findMaskBinding(first.record.id)!.image + ); + }); + + it('reuses the same binding on a second rasterize', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + + const first = targetOf('img-1', segment.segmentId); + const second = targetOf('img-1', segment.segmentId); + + expect(second.voxels.image()).toBe(first.voxels.image()); + expect(store().maskLayersForImage('img-1')).toHaveLength(1); + }); + + it('leaves the selected type alone', async () => { + await seatImage('img-1'); + const active = makeMask('img-1', 'Active'); + const other = makeMask('img-1', 'Other'); + segments().selectSegment(active.segmentId); + + targetOf('img-1', other.segmentId); + + expect(segments().selectedSegmentId.value).toBe(active.segmentId); + }); + + it('takes this image record for a type painted on another image', async () => { + await seatImage('img-1'); + await seatImage('img-2'); + const elsewhere = makeMask('img-2', 'Tumor'); + + const target = targetOf('img-1', elsewhere.segmentId); + + expect(target.maskId).not.toBe(elsewhere.record.id); + expect(store().getMask(target.maskId).segmentId).toBe(elsewhere.segmentId); + }); + + it('refuses a locked record before allocating storage for it', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + lockSegment(segment.record.id, true); + + expect(resolveRasterizeTarget('img-1', segment.segmentId)).toBeUndefined(); + + expect( + store().getMask(segment.record.id).representations.labelmap + ).toBeUndefined(); + expect(store().maskLayersForImage('img-1')).toEqual([]); + expect( + useMessageStore().messages.map((message) => message.title) + ).toContain('Cannot rasterize into a locked segment'); + }); + + it('rasterizes into a minted type when nothing is selected', async () => { + await seatImage('img-1'); + + const target = targetOf('img-1', undefined); + + const segmentation = store().getSegmentationForImage('img-1'); + expect(Object.keys(segmentation!.masks)).toHaveLength(1); + expect(segments().selectedSegmentId.value).toBe(target.segmentId); + expect(target.voxels.image()).toBe( + store().findMaskBinding(target.maskId)!.image + ); + expect(target.maskId).toBe(Object.keys(segmentation!.masks)[0]); + }); + + it('reuses the default segment on a second rasterize', async () => { + await seatImage('img-1'); + + const first = targetOf('img-1', undefined); + const second = targetOf('img-1', undefined); + + expect(second.voxels.image()).toBe(first.voxels.image()); + expect(second.labelValue).toBe(first.labelValue); + expect( + Object.keys(store().getSegmentationForImage('img-1')!.masks) + ).toHaveLength(1); + }); + + it('hands back the accessor the polygon writes through', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + + const target = targetOf('img-1', segment.segmentId); + target.voxels.ensureContains([0, 3, 0, 0, 0, 0]); + // fillPoly writes voxel offsets into the live buffer, so a copy would be + // rasterized and thrown away. + target.voxels.scalars()[3] = target.labelValue; + + expect( + store() + .findMaskBinding(target.maskId)! + .image.getPointData() + .getScalars() + .getData()[3] + ).toBe(target.labelValue); + }); + + it('rasterizes into a minted type when the tool names a deleted one', async () => { + await seatImage('img-1'); + const segment = makeMask('img-1', 'Tumor'); + segments().deleteSegment(segment.segmentId); + + // The tool keeps the deleted type's id; that must not block rasterizing. + const target = targetOf('img-1', segment.segmentId); + + expect(target.segmentId).not.toBe(segment.segmentId); + expect(store().getSegmentationForImage('img-1')!.masks).toHaveProperty( + target.maskId + ); + }); +}); diff --git a/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts b/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts new file mode 100644 index 000000000..2dcfbd9ab --- /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.togglePreview(); + + 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..918767d2e --- /dev/null +++ b/src/segmentation/editing/__tests__/silentWorker.ts @@ -0,0 +1,60 @@ +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; + +/** + * A Comlink endpoint that accepts messages and never answers one, which is + * what a worker that failed to load, or that is busy with a job nobody wants + * any more, looks like from the page. + */ +export class SilentWorker { + static created: SilentWorker[] = []; + + private listeners = new Map void>>(); + + posted: unknown[] = []; + + terminated = false; + + constructor() { + SilentWorker.created.push(this); + } + + addEventListener(type: string, listener: (event: unknown) => void) { + const forType = this.listeners.get(type) ?? []; + forType.push(listener); + this.listeners.set(type, forType); + } + + removeEventListener(type: string, listener: (event: unknown) => void) { + const forType = this.listeners.get(type) ?? []; + this.listeners.set( + type, + forType.filter((entry) => entry !== listener) + ); + } + + postMessage(message: unknown) { + this.posted.push(message); + } + + terminate() { + this.terminated = true; + } + + /** What the browser does to a worker that fails: an event, no reply. */ + emit(event: { type: string; message?: string }) { + [...(this.listeners.get(event.type) ?? [])].forEach((listener) => + listener(event) + ); + } +} + +export type SilentApi = { smooth: (value: number) => Promise }; + +/** A host over silent workers, and the workers it has started so far. */ +export function hostOverSilentWorkers() { + SilentWorker.created = []; + const host = createProcessWorkerHost( + () => new SilentWorker() as unknown as Worker + ); + return { host, workers: SilentWorker.created }; +} 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..a013d1b7c --- /dev/null +++ b/src/segmentation/editing/algorithms/__tests__/fillHoles.spec.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { fillHoles } from '@/src/segmentation/editing/algorithms/fillHoles'; + +// Build a flat single-slice label map (axis 2, k=0) from a 2D grid. +// grid[j][i] maps to flat index i + j*dimI. +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] }; +} + +/** fillHoles over the whole first axial slice, for label 1. */ +const fillAxialSlice = ( + data: Uint8Array, + dimensions: [number, number, number] +) => fillHoles({ data, dimensions, axis: 2, sliceIndex: 0, label: 1 }); + +/** A grid as the flat buffer fillHoles returns, so the two compare directly. */ +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 background but preserves encircled segments', () => { + // 7 wide x 5 tall. Left block is a ring of 1 enclosing 0s and 2s; a stray + // 2 sits outside the ring on the right border. + const { data, dimensions } = flatFromGrid([ + [1, 1, 1, 1, 1, 0, 2], + [1, 0, 2, 0, 1, 0, 0], + [1, 2, 2, 2, 1, 0, 0], + [1, 0, 2, 0, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + ]); + const out = fillAxialSlice(data, dimensions); + // Enclosed background (0) becomes 1; the enclosed 2s stay 2. + expect(Array.from(out)).toEqual( + flatOf([ + [1, 1, 1, 1, 1, 0, 2], + [1, 1, 2, 1, 1, 0, 0], + [1, 2, 2, 2, 1, 0, 0], + [1, 1, 2, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 0, 0], + ]) + ); + }); + + it('does not override a segment it fully encircles', () => { + // SegmentMask 1 forms a ring around segment 2 with a background gap between. + const { data, dimensions } = flatFromGrid([ + [1, 1, 1, 1, 1], + [1, 0, 0, 0, 1], + [1, 0, 2, 0, 1], + [1, 0, 0, 0, 1], + [1, 1, 1, 1, 1], + ]); + const out = fillAxialSlice(data, dimensions); + // The background gap fills with 1; the encircled 2 is untouched. + expect(Array.from(out)).toEqual( + flatOf([ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 2, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + ]) + ); + }); + + 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, label: 1 }); + 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, + label: 1, + }); + expect(out[holeOffset(0)]).toBe(0); + expect(out[holeOffset(1)]).toBe(1); + expect(out[holeOffset(2)]).toBe(0); + }); +}); diff --git a/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts b/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts new file mode 100644 index 000000000..4a809e805 --- /dev/null +++ b/src/segmentation/editing/algorithms/__tests__/gaussianSmooth.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { fullExtent, type Extent3D } from '@/src/segmentation/geometry'; +import { gaussianSmoothLabelMapWorker } from '@/src/segmentation/editing/algorithms/gaussianSmooth.worker'; + +const LABEL = 3; +type Dims = [number, number, number]; + +/** Reconstructs the whole parent, including growth outside the input mask. */ +function smooth( + data: Uint8Array, + dimensions: Dims, + maskExtent: Extent3D = fullExtent(dimensions), + parentDimensions: Dims = dimensions +) { + const { scalars, extent } = gaussianSmoothLabelMapWorker({ + data, + dimensions, + spacing: [1, 1, 1], + maskExtent, + parentDimensions, + params: { sigma: 1, label: LABEL }, + }); + const [pi, pj, pk] = parentDimensions; + const parent = new Uint8Array(pi * pj * pk); + let offset = 0; + 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)] = scalars[offset++]; + } + } + } + return parent; +} + +function boxInParent(extent: Extent3D, dimensions: Dims) { + const [pi, pj, pk] = dimensions; + const parent = new Uint8Array(pi * pj * pk); + 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; + } + } + } + return parent; +} + +describe('gaussianSmoothLabelMapWorker', () => { + it('smooths an isolated voxel away even when the mask is that one voxel', () => { + const smoothed = smooth( + new Uint8Array([LABEL]), + [1, 1, 1], + [1, 1, 1, 1, 1, 1], + [3, 3, 3] + ); + expect(smoothed.every((value) => value === 0)).toBe(true); + }); + + it.each([ + [2, 4, 2, 4, 2, 4], + [1, 3, 1, 3, 1, 3], + [0, 2, 2, 4, 2, 4], + [1, 4, 1, 4, 1, 4], + [4, 7, 4, 7, 4, 7], + ] as Extent3D[])( + 'preserves the full-parent output for a tight mask at [%i, %i, %i, %i, %i, %i]', + (...extent) => { + const parentDimensions: Dims = [9, 9, 9]; + const dimensions: Dims = [ + extent[1] - extent[0] + 1, + extent[3] - extent[2] + 1, + extent[5] - extent[4] + 1, + ]; + const data = new Uint8Array( + dimensions[0] * dimensions[1] * dimensions[2] + ).fill(LABEL); + const croppedResult = smooth(data, dimensions, extent, parentDimensions); + const parentResult = smooth( + boxInParent(extent, parentDimensions), + parentDimensions + ); + expect(croppedResult).toEqual(parentResult); + } + ); + + it('retains all 62 voxels when mirroring grows a box toward parent faces', () => { + const smoothed = smooth( + new Uint8Array(64).fill(LABEL), + [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); + }); + + it('erodes a cropped cube at its corners and keeps its centre', () => { + const smoothed = smooth( + new Uint8Array(27).fill(LABEL), + [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); + }); + + it('mirrors only at parent faces, not at the mask allocation', () => { + const data = new Uint8Array(27).fill(LABEL); + 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(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] + ); + }); +}); diff --git a/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts b/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts new file mode 100644 index 000000000..d27dc1fd7 --- /dev/null +++ b/src/segmentation/editing/algorithms/__tests__/gaussianSmoothGolden.spec.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from 'vitest'; +import { gaussianSmoothLabelMapWorker } from '@/src/segmentation/editing/algorithms/gaussianSmooth.worker'; + +// 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 string is the volume row by row, '1' where the label survives. To +// refresh one after a deliberate change in behaviour, print +// `Array.from(smooth(...)).map((v) => (v ? 1 : 0)).join('')` and split it into +// rows of DIMENSIONS[0]. + +const LABEL = 3; +const DIMENSIONS: [number, number, number] = [9, 8, 7]; + +const SIGMA_0_6 = + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '011100100' + + '011101100' + + '011011100' + + '000111100' + + '001111100' + + '000000000' + + '000000000' + + '000000000' + + '011101100' + + '011111100' + + '011111100' + + '001111100' + + '011111100' + + '000000000' + + '000000000' + + '000000000' + + '011011100' + + '011111100' + + '001111100' + + '011111100' + + '011111000' + + '000000000' + + '000000000' + + '000000000' + + '000111100' + + '001111100' + + '011111100' + + '011111000' + + '011110000' + + '000000000' + + '000000000' + + '000000000' + + '001111100' + + '011111100' + + '011111000' + + '011110000' + + '011100100' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000'; + +const SIGMA_1_0 = + '000000000' + + '000000000' + + '000000000' + + '000001000' + + '000011000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '001111000' + + '001111100' + + '000111000' + + '000010000' + + '000000000' + + '000000000' + + '000000000' + + '001111000' + + '011111100' + + '011111100' + + '001111100' + + '001111000' + + '000000000' + + '000000000' + + '000001000' + + '001111100' + + '011111100' + + '011111100' + + '011111100' + + '001111000' + + '000000000' + + '000000000' + + '000011000' + + '000111000' + + '001111100' + + '011111100' + + '011111000' + + '001110000' + + '000000000' + + '000000000' + + '000000000' + + '000111000' + + '001111000' + + '011111000' + + '011110000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000110000' + + '001110000' + + '001100000' + + '000000000' + + '000000000' + + '000000000'; + +const ANISOTROPIC = + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '001100000' + + '011110000' + + '111111100' + + '111111100' + + '001111100' + + '000111000' + + '000000000' + + '000000000' + + '001000000' + + '011111100' + + '111111100' + + '011111100' + + '001111100' + + '001111000' + + '000000000' + + '000000000' + + '000001000' + + '000111100' + + '001111100' + + '011111100' + + '011111100' + + '011111000' + + '000000000' + + '000000000' + + '000011000' + + '000111100' + + '001111100' + + '011111100' + + '111111000' + + '011110000' + + '000000000' + + '000000000' + + '000111000' + + '001111100' + + '011111100' + + '111111000' + + '111111000' + + '011100000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000' + + '000000000'; +/** An asymmetric blob with a pitted interior and two lone corner voxels. */ +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; + }; + for (let k = 1; k <= 5; k += 1) { + for (let j = 1; j <= 5; j += 1) { + for (let i = 1; i <= 6; i += 1) { + if ((i + j + k) % 7 !== 0) mark(i, j, k); + } + } + } + mark(8, 0, 0); + mark(0, 7, 6); + return data; +}; + +const smooth = (sigma: number, spacing: [number, number, number]) => + gaussianSmoothLabelMapWorker({ + data: blob(), + dimensions: DIMENSIONS, + spacing, + maskExtent: [0, 8, 0, 7, 0, 6], + parentDimensions: DIMENSIONS, + params: { sigma, label: LABEL }, + }).scalars; + +const asBits = (output: ArrayLike) => + Array.from(output, (value) => (value ? '1' : '0')).join(''); + +describe('gaussian smooth golden output', () => { + it('matches byte for byte at a sigma below one voxel', () => { + expect(asBits(smooth(0.6, [1, 1, 1]))).toBe(SIGMA_0_6); + }); + + it('matches byte for byte at a one-voxel sigma', () => { + expect(asBits(smooth(1.0, [1, 1, 1]))).toBe(SIGMA_1_0); + }); + + it('matches byte for byte through anisotropic spacing', () => { + expect(asBits(smooth(1.0, [1, 1, 3]))).toBe(ANISOTROPIC); + }); +}); diff --git a/src/segmentation/editing/algorithms/fillHoles.ts b/src/segmentation/editing/algorithms/fillHoles.ts new file mode 100644 index 000000000..a300634d3 --- /dev/null +++ b/src/segmentation/editing/algorithms/fillHoles.ts @@ -0,0 +1,167 @@ +import { TypedArray } from '@kitware/vtk.js/types'; + +// 4-connected neighbor offsets, shared so the flood-fill loops never allocate +// a neighbor array per visited voxel. +const NEIGHBOR_DU = [-1, 1, 0, 0]; +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. + data: MaskData; + // Label-map 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; +}; + +// One slice's in-plane coordinate system: its two dimensions and the flat +// offset a plane coordinate maps to. +type Plane = { + uDim: number; + vDim: number; + offset: (u: number, v: number) => number; +}; + +// Everything a flood fill over one slice works on. `visited` and `stack` are +// reused across slices, so the whole run allocates them once. +// `visited`: 0 = unvisited, 1 = outside (border-connected), 2 = hole. +type Flood = { + plane: Plane; + out: MaskData; + label: number; + visited: Uint8Array; + stack: number[]; +}; + +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; + 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; + stack.push(np); + } + } + } +} + +// 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 seed = (u: number, v: number) => { + const p = u + v * plane.uDim; + if (visited[p] === 0 && out[plane.offset(u, v)] !== label) { + visited[p] = 1; + stack.push(p); + } + }; + for (let u = 0; u < plane.uDim; u++) { + seed(u, 0); + seed(u, plane.vDim - 1); + } + for (let v = 0; v < plane.vDim; v++) { + seed(0, v); + seed(plane.uDim - 1, v); + } + drain(flood, 1); +} + +// 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; + 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); + } + } +} + +// Fills enclosed background regions ("holes") on 2D slices of a label map. +// 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. +export function fillHoles(opts: FillHolesOptions) { + const { data, dimensions, axis, sliceIndex, label } = opts; + const out = data.slice(); + + const strides = [1, dimensions[0], dimensions[0] * dimensions[1]]; + const sliceStride = strides[axis]; + const sliceCount = dimensions[axis]; + + // The two in-plane axes (everything that isn't the slice axis). + const [uAxis, vAxis] = [0, 1, 2].filter((a) => a !== axis); + const uDim = dimensions[uAxis]; + const vDim = dimensions[vAxis]; + const uStride = strides[uAxis]; + const vStride = strides[vAxis]; + + const visited = new Uint8Array(uDim * vDim); + const stack: number[] = []; + + const firstSlice = sliceIndex ?? 0; + const lastSlice = sliceIndex ?? sliceCount - 1; + + for (let slice = firstSlice; slice <= lastSlice; slice++) { + const base = slice * sliceStride; + visited.fill(0); + + const flood: Flood = { + plane: { + uDim, + vDim, + offset: (u, v) => base + u * uStride + v * vStride, + }, + out, + label, + visited, + stack, + }; + + markOutside(flood); + fillEnclosed(flood); + } + + return out; +} diff --git a/src/core/tools/paint/fillHoles.worker.ts b/src/segmentation/editing/algorithms/fillHoles.worker.ts similarity index 76% rename from src/core/tools/paint/fillHoles.worker.ts rename to src/segmentation/editing/algorithms/fillHoles.worker.ts index 6e3f30995..bf548bb4b 100644 --- a/src/core/tools/paint/fillHoles.worker.ts +++ b/src/segmentation/editing/algorithms/fillHoles.worker.ts @@ -1,5 +1,8 @@ import * as Comlink from 'comlink'; -import { fillHoles, FillHolesOptions } from '@/src/core/tools/paint/fillHoles'; +import { + fillHoles, + FillHolesOptions, +} from '@/src/segmentation/editing/algorithms/fillHoles'; // Runs the pure flood-fill off the main thread so whole-volume fills do not // freeze the UI, mirroring gaussianSmooth.worker.ts. diff --git a/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts b/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts new file mode 100644 index 000000000..d59ad472c --- /dev/null +++ b/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts @@ -0,0 +1,384 @@ +import * as Comlink from 'comlink'; +import { TypedArray } from '@kitware/vtk.js/types'; +import { createTypedArrayLike } from '@/src/utils'; +import { + extentSize, + extentUnion, + type Extent3D, +} from '@/src/segmentation/geometry'; + +export interface GaussianSmoothParams { + sigma: number; + label: number; +} + +export interface GaussianSmoothInput { + data: TypedArray | number[]; + dimensions: number[]; + spacing: [number, number, number]; + maskExtent: [number, number, number, number, number, number]; + parentDimensions: [number, number, number]; + params: GaussianSmoothParams; +} + +function generateGaussianKernel(sigma: number, radiusFactor = 1.5) { + const radius = Math.ceil(sigma * radiusFactor); + const size = 2 * radius + 1; + const kernel = new Float32Array(size); + const center = radius; + let sum = 0; + + for (let i = 0; i < size; i++) { + const x = i - center; + // VTK formula: exp(-(x * x) / (std * std * 2.0)) + const value = Math.exp(-(x * x) / (sigma * sigma * 2.0)); + kernel[i] = value; + sum += value; + } + + // Normalize kernel + for (let i = 0; i < size; i++) { + kernel[i] /= sum; + } + + return kernel; +} + +// Helper for robust boundary handling (mirroring) +function mirrorCoord(sampleCoord: number, axisDim: number) { + let finalCoord = sampleCoord; + if (sampleCoord < 0) { + finalCoord = -sampleCoord; // Reflect + } else if (sampleCoord >= axisDim) { + finalCoord = 2 * axisDim - sampleCoord - 2; // Reflect + } + // Clamp to ensure it's within bounds, useful if kernel is very large + return Math.max(0, Math.min(axisDim - 1, finalCoord)); +} + +/** + * What stays fixed for one axis pass: the two buffers, the kernel, and how a + * line along the convolved axis is addressed. Built once per pass, so walking + * a line allocates nothing. + */ +interface AxisPass { + inputData: TypedArray | number[]; + outputData: TypedArray | number[]; + kernel: Float32Array; + kernelCenter: number; + // Voxels along the convolved axis, and the index step between them. + count: number; + stride: number; +} + +// Convolves the one line of voxels that starts at `lineStart` and runs along +// the pass's axis. Every axis reduces to this, because a line differs only in +// where it starts, how long it is, and how far apart its voxels sit. +function convolveLine(pass: AxisPass, lineStart: number) { + const { inputData, outputData, kernel, kernelCenter, count, stride } = pass; + const kernelSize = kernel.length; + + for (let i = 0; i < count; i++) { + let sum = 0; + for (let k = 0; k < kernelSize; k++) { + const sample = mirrorCoord(i + k - kernelCenter, count); + sum += inputData[sample * stride + lineStart] * kernel[k]; + } + + outputData[i * stride + lineStart] = sum; + } +} + +function convolve1D( + inputData: TypedArray | number[], + outputData: TypedArray | number[], + kernel: Float32Array, + volume: { dimensions: number[]; axis: 0 | 1 | 2 } +) { + const { dimensions, axis } = volume; + const [dimX, dimY] = dimensions; + const strides = [1, dimX, dimX * dimY]; + const pass: AxisPass = { + inputData, + outputData, + kernel, + kernelCenter: Math.floor(kernel.length / 2), + count: dimensions[axis], + stride: strides[axis], + }; + + // The two axes the pass does not walk, the widest-striding one outermost: + // z, then y, then x. That is the loop order each axis wants for cache + // efficiency, so convolving along X visits z, y, x, along Y visits z, x, y, + // and along Z visits y, x, z. + const outer = axis === 2 ? 1 : 2; + const inner = axis === 0 ? 1 : 0; + + for (let o = 0; o < dimensions[outer]; o++) { + const outerOffset = o * strides[outer]; + for (let i = 0; i < dimensions[inner]; i++) { + convolveLine(pass, outerOffset + i * strides[inner]); + } + } +} + +function gaussianFilter3D( + inputData: TypedArray | number[], + dimensions: number[], + sigmaPixels: [number, number, number], + radiusFactor = 1.5 +) { + 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 temp = new Float32Array(totalSize); + const output = new Float32Array(totalSize); + + convolve1D(inputData, output, kernelX, { dimensions, axis: 0 }); + convolve1D(output, temp, kernelY, { dimensions, axis: 1 }); + convolve1D(temp, output, kernelZ, { dimensions, axis: 2 }); + + 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. +interface RowScan { + data: TypedArray | number[]; + bounds: number[]; + dimX: number; + sliceSize: number; + label: number; +} + +// Widens the bounds over one x row. 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 + * padding ring outside the volume is skipped by clipping the loops, not tested + * per voxel. + */ +function forEachClippedVoxel( + dimensions: number[], + bounds: number[], + visit: (origIndex: number, subIndex: number) => void +) { + const [dimX, dimY, dimZ] = dimensions; + const [minX, maxX, minY, maxY, minZ, maxZ] = bounds; + const subDimX = maxX - minX + 1; + const subDimY = maxY - minY + 1; + const lastX = Math.min(maxX, dimX - 1); + + for (let z = Math.max(minZ, 0); z <= Math.min(maxZ, dimZ - 1); z += 1) { + for (let y = Math.max(minY, 0); y <= Math.min(maxY, dimY - 1); y += 1) { + const rowOrig = y * dimX + z * dimX * dimY; + const rowSub = (y - minY) * subDimX + (z - minZ) * subDimX * subDimY; + for (let x = Math.max(minX, 0); x <= lastX; x += 1) { + visit(x + rowOrig, x - minX + rowSub); + } + } + } +} + +/** + * 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 + * array, live at the same time as this one. + */ +function extractSubMask( + data: TypedArray | number[], + dimensions: number[], + bounds: number[], + label: number +) { + const [minX, maxX, minY, maxY, minZ, maxZ] = bounds; + const subDims = [maxX - minX + 1, maxY - minY + 1, maxZ - minZ + 1]; + // Zero filled, so everything outside the buffer stays background. + const subMask = new Float32Array(subDims[0] * subDims[1] * subDims[2]); + + forEachClippedVoxel(dimensions, bounds, (origIndex, subIndex) => { + subMask[subIndex] = data[origIndex] === label ? 255.0 : 0.0; + }); + + return { subMask, subDims }; +} + +// Output storage includes the padding ring: mirroring at a parent face can +// turn on voxels beyond the input mask's allocation. +function copySubVolumeBack( + subData: Float32Array, + originalData: TypedArray | number[], + region: { dimensions: number[]; bounds: number[] }, + label: 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; + } + } + ); +} + +export function gaussianSmoothLabelMapWorker(input: GaussianSmoothInput) { + const { + data: originalData, + dimensions, + spacing, + maskExtent, + parentDimensions, + params, + } = input; + const { sigma, label } = params; + + if (sigma <= 0) { + throw new Error('Sigma must be positive'); + } + + const sigmaPixels: [number, number, number] = [ + sigma / spacing[0], + sigma / spacing[1], + 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, + }); + const { subMask, subDims } = extractSubMask( + originalData, + dimensions, + expandedBounds, + label + ); + + 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)] + ); + + copySubVolumeBack( + smoothedSubMask, + outputData, + { dimensions: outputDimensions, bounds: smoothedBoundsInOutput }, + label + ); + + return { scalars: outputData, extent }; +} + +const workerApi = { + gaussianSmoothLabelMapWorker, +}; + +Comlink.expose(workerApi); diff --git a/src/segmentation/editing/coordinator.ts b/src/segmentation/editing/coordinator.ts new file mode 100644 index 000000000..0bb466539 --- /dev/null +++ b/src/segmentation/editing/coordinator.ts @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia'; + +/** Coordinates temporary mask previews with competing edits and durable reads. */ +export const useSegmentationEditsStore = defineStore( + 'segmentationEdits', + () => { + let cancelPreview: (() => void) | undefined; + + function beforeEdit() { + const cancel = cancelPreview; + cancelPreview = undefined; + cancel?.(); + } + + function hold(cancel: () => void) { + beforeEdit(); + cancelPreview = cancel; + } + + function release(cancel: () => void) { + if (cancelPreview === cancel) cancelPreview = undefined; + } + + // Save and export read committed voxels, resolving an unconfirmed preview. + const beforeRead = beforeEdit; + + return { beforeEdit, beforeRead, hold, release }; + } +); diff --git a/src/segmentation/editing/fillBetween.ts b/src/segmentation/editing/fillBetween.ts new file mode 100644 index 000000000..7345d8a78 --- /dev/null +++ b/src/segmentation/editing/fillBetween.ts @@ -0,0 +1,51 @@ +import { defineStore } from 'pinia'; +import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; +import { TypedArray } from '@kitware/vtk.js/types'; +import { morphologicalContourInterpolation } from '@itk-wasm/morphological-contour-interpolation'; +import type { Image } from 'itk-wasm'; +import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import { reframeMaskScalars } from '@/src/segmentation/masks/storage'; +import { fullExtent } from '@/src/segmentation/geometry'; + +type Interpolate = ( + image: Image, + options: { label: number } +) => Promise<{ outputImage: Image }>; + +export const useFillBetweenStore = defineStore('fillBetween', () => { + async function computeAlgorithm( + target: ProcessTarget, + interpolate: Interpolate = morphologicalContourInterpolation + ) { + const image = vtkImageData.newInstance({ + origin: target.parentOrigin, + spacing: target.spacing, + direction: target.direction, + }); + image.setDimensions(target.parentDimensions); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: reframeMaskScalars( + target.scalars, + target.maskExtent, + fullExtent(target.parentDimensions) + ), + }) + ); + const extent = fullExtent(target.parentDimensions); + // Interpolation's alignment and dilation depend on image boundaries, and + // can leave the input contours' box. Only this transient input spans the + // parent; the process commits the occupied result to bounded storage. + const input = vtkITKHelper.convertVtkToItkImage(image); + image.delete(); + const out = await interpolate(input, { label: target.labelValue }); + return { scalars: out.outputImage.data as TypedArray, extent }; + } + + return { + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/fillHoles.ts b/src/segmentation/editing/fillHoles.ts new file mode 100644 index 000000000..a6a9da28e --- /dev/null +++ b/src/segmentation/editing/fillHoles.ts @@ -0,0 +1,121 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { useViewStore } from '@/src/store/views'; +import { useViewSliceStore } from '@/src/store/view-configs/slicing'; +import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import { getEffectiveView } from '@/src/core/views/effectiveView'; +import { fillHolesWorker } from '@/src/segmentation/editing/algorithms/fillHoles.worker'; +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; +import { getLPSDirections } from '@/src/utils/lps'; +import type { LPSAxis } from '@/src/types/lps'; + +export enum FillHolesSliceScope { + CurrentSlice = 'currentSlice', + WholeVolume = 'wholeVolume', +} + +export enum FillHolesSegmentScope { + AllSegments = 'allSegments', + SelectedSegment = 'selectedSegmentOn', +} + +type WorkerApi = { + fillHolesWorker: typeof fillHolesWorker; +}; + +const workerHost = createProcessWorkerHost( + () => + new Worker( + new URL( + '@/src/segmentation/editing/algorithms/fillHoles.worker.ts', + import.meta.url + ), + { type: 'module' } + ) +); + +/** + * The current parent slice in the mask's own index space, or undefined when the + * mask does not reach it. A segment's mask is cropped to its own extent, so a + * slice outside it converts to an index the worker would fold back onto a real + * slice of the mask and fill the wrong one. + */ +function maskSliceIndex( + view: { viewInfo: { id: string }; axis: LPSAxis }, + target: ProcessTarget, + axis: number +) { + const sliceConfig = useViewSliceStore().getConfig( + view.viewInfo.id, + target.parentImageId + ); + const sliceIndex = sliceConfig.slice - target.maskExtent[axis * 2]; + const sliceCount = target.dimensions[axis]; + return sliceIndex < 0 || sliceIndex >= sliceCount ? undefined : sliceIndex; +} + +export const useFillHolesStore = defineStore('fillHoles', () => { + const sliceScope = ref(FillHolesSliceScope.CurrentSlice); + const segmentScope = ref(FillHolesSegmentScope.AllSegments); + + function setSliceScope(value: FillHolesSliceScope) { + sliceScope.value = value; + } + + function setSegmentScope(value: FillHolesSegmentScope) { + segmentScope.value = value; + } + + async function computeAlgorithm(target: ProcessTarget) { + const viewStore = useViewStore(); + + // Fill Holes works on the slice plane of the 2D view the user is on, so a + // 2D view must be active to know which axis (and slice) to operate on. + const effectiveView = getEffectiveView(viewStore.activeView); + if (effectiveView?.kind !== 'volume2D') { + throw new Error( + 'Fill Holes needs an active 2D slice view. Click a 2D view, then try again.' + ); + } + + const labelMapLpsOrientation = getLPSDirections( + Float32Array.from(target.direction) + ); + const axis = labelMapLpsOrientation[effectiveView.axis]; + const { dimensions, scalars: data } = target; + + const currentSlice = sliceScope.value === FillHolesSliceScope.CurrentSlice; + const sliceIndex = currentSlice + ? maskSliceIndex(effectiveView, target, axis) + : undefined; + if (currentSlice && sliceIndex === undefined) { + // The user named one segment, so say the slice misses it. An + // all-segments pass simply has nothing to do in this one. + if (segmentScope.value === FillHolesSegmentScope.SelectedSegment) { + throw new Error( + 'the selected segment has nothing on this slice. Scroll to a slice it covers, then try again.' + ); + } + return undefined; + } + + const scalars = await workerHost.call((worker) => + worker.fillHolesWorker({ + data, + dimensions, + axis, + sliceIndex, + label: target.labelValue, + }) + ); + return { scalars, extent: target.maskExtent }; + } + + return { + sliceScope, + segmentScope, + setSliceScope, + setSegmentScope, + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/gaussianSmooth.ts b/src/segmentation/editing/gaussianSmooth.ts new file mode 100644 index 000000000..7a1b8c9c1 --- /dev/null +++ b/src/segmentation/editing/gaussianSmooth.ts @@ -0,0 +1,66 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { gaussianSmoothLabelMapWorker } from '@/src/segmentation/editing/algorithms/gaussianSmooth.worker'; +import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; +import { createProcessWorkerHost } from '@/src/segmentation/editing/processWorker'; + +export const DEFAULT_SIGMA = 1.0; +export const MIN_SIGMA = 0.1; +export const MAX_SIGMA = 5.0; + +// Worker management +type WorkerApi = { + gaussianSmoothLabelMapWorker: typeof gaussianSmoothLabelMapWorker; +}; + +const workerHost = createProcessWorkerHost( + () => + new Worker( + new URL( + '@/src/segmentation/editing/algorithms/gaussianSmooth.worker.ts', + import.meta.url + ), + { type: 'module' } + ) +); + +async function gaussianSmoothLabelMap( + target: ProcessTarget, + params: { sigma: number; label: number } +) { + const workerInput = { + data: target.scalars, + dimensions: target.dimensions, + spacing: target.spacing, + maskExtent: target.maskExtent, + parentDimensions: target.parentDimensions, + params, + }; + + return workerHost.call((worker) => + 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(target: ProcessTarget) { + const params = { + sigma: sigma.value, + label: target.labelValue, + }; + + return gaussianSmoothLabelMap(target, params); + } + + return { + sigma, + setSigma, + computeAlgorithm, + }; +}); diff --git a/src/segmentation/editing/paintProcess.ts b/src/segmentation/editing/paintProcess.ts new file mode 100644 index 000000000..76316bc81 --- /dev/null +++ b/src/segmentation/editing/paintProcess.ts @@ -0,0 +1,643 @@ +import { defineStore } from 'pinia'; +import { ref, computed, watch } from 'vue'; +import { TypedArray } from '@kitware/vtk.js/types'; +import { + LABELMAP_BACKGROUND_VALUE, + type VoxelStorage, +} from '@/src/segmentation/model'; +import { + extentContains, + extentSize, + extentUnion, + fullExtent, + isEmptyExtent, + markedExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { usePaintToolStore } from '@/src/store/tools/paint'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { PaintMode } from '@/src/core/tools/paint'; +import { useMessageStore } from '@/src/store/messages'; +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { reframeMaskScalars } from '@/src/segmentation/masks/storage'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import { terminateProcessWorkers } from '@/src/segmentation/editing/processWorker'; + +export enum ProcessType { + FillHoles = 'fillHoles', + FillBetween = 'fillBetween', + GaussianSmooth = 'gaussianSmooth', +} + +type StartState = { + step: 'start'; +}; + +type TargetedState = { + activeParentImageID: string; + targetMaskIds: string[]; + // The segment whose selection owns the run, absent for an all-segments run: + // that run belongs to no one segment, so no selection change is about it. + watchedMaskId?: string; +}; + +type ComputingState = TargetedState & { + step: 'computing'; +}; + +/** One segment's slot in a run: what it held, and what the algorithm made. */ +type PreviewRun = { + target: ResolvedTarget; + extent: Extent3D; + originalScalars: TypedArray; + processedScalars: TypedArray | number[]; +}; + +/** Algorithm output positioned in parent index space, including any growth. */ +export type ProcessResult = { + scalars: TypedArray | number[]; + extent: Extent3D; +}; + +type PreviewingState = TargetedState & { + step: 'previewing'; + runs: PreviewRun[]; + showingOriginal: boolean; +}; + +type ProcessState = StartState | ComputingState | PreviewingState; + +/** Detached algorithm input. Mutating it cannot change a stored mask. */ +export type ProcessTarget = { + parentImageId: string; + parentDimensions: [number, number, number]; + parentOrigin: number[]; + maskId: string; + scalars: TypedArray; + dimensions: [number, number, number]; + spacing: [number, number, number]; + direction: number[]; + maskExtent: Extent3D; + labelValue: number; +}; + +type ResolvedTarget = ProcessTarget & { voxels: VoxelStorage }; + +/** What a resolved start has to run, and whose selection owns it. */ +type ResolvedRun = { + targets: ResolvedTarget[]; + watchedMaskId?: string; +}; + +/** + * One segment's new mask contents, or `undefined` where the algorithm has + * nothing to do to that segment: a run with no result is dropped rather than + * written back, so an untouched mask keeps its buffer and the renderer is not + * invalidated for it. + */ +export type ProcessAlgorithm = ( + target: ProcessTarget +) => Promise; + +/** Validate placement and keep only the space the preview or original needs. */ +function previewExtent(target: ProcessTarget, result: ProcessResult) { + if ( + !result.extent.every(Number.isInteger) || + isEmptyExtent(result.extent) || + !extentContains(fullExtent(target.parentDimensions), result.extent) || + extentSize(result.extent).reduce((a, b) => a * b, 1) !== + result.scalars.length + ) { + throw new Error('Process result does not fit its parent-grid extent'); + } + if (extentContains(target.maskExtent, result.extent)) + return target.maskExtent; + const occupied = markedExtent( + result.scalars, + result.extent, + target.labelValue + ); + return isEmptyExtent(occupied) + ? target.maskExtent + : extentUnion(target.maskExtent, occupied); +} + +export const usePaintProcessStore = defineStore('paintProcess', () => { + const processState = ref({ step: 'start' }); + const activeProcessType = ref(ProcessType.FillHoles); + let activeProcessRunId = 0; + + const processStep = computed(() => processState.value.step); + + const showingOriginal = computed(() => { + const state = processState.value; + return state.step === 'previewing' ? state.showingOriginal : false; + }); + + const edits = useSegmentationEditsStore(); + + function resetState() { + edits.release(cancelProcess); + processState.value = { step: 'start' }; + } + + // Storage can be deleted while a preview is up, and the accessor re-resolves, + // so every preview write is conditional on the storage still being there. + // A mask another tool grew no longer has the shape the snapshot was taken + // at, and a snapshot of the old shape cannot be written back at all. + function writeIfPresent( + voxels: VoxelStorage, + scalars: TypedArray | number[] + ) { + if (!voxels.exists()) return; + if (voxels.scalars().length !== scalars.length) return; + voxels.apply(scalars); + } + + /** + * The extent and strides a run's mask offsets are taken against, absent when + * the mask has been reshaped since and they no longer address it. The binding + * is read here because `target.voxels` carries none and growth moves it. + */ + function runMaskBounds(run: PreviewRun) { + const binding = segmentationStore.findMaskBinding(run.target.maskId); + if (!binding || isEmptyExtent(binding.extent)) return undefined; + const extent = [...binding.extent] as Extent3D; + const [mi, mj] = extentSize(extent); + const addressable = extent.every( + (value, axis) => value === run.extent[axis] + ); + return addressable ? { extent, mi, mj } : undefined; + } + + /** + * Drops from the result every voxel the algorithm turned on that another + * segment already holds: a process is a `sweep`, so it takes nothing from a + * neighbour. Masking the result rather than the storage keeps the preview + * honest: what it shows is what confirm leaves behind. + */ + function maskVoxelsOtherSegmentsHold(run: PreviewRun) { + const bounds = runMaskBounds(run); + if (!bounds) return; + + // Absent when no other segment's box reaches this one, which is the common + // case: nothing can then be dropped, so the result is not walked at all. + const claimVoxel = segmentationStore.voxelClaim( + run.target.maskId, + 'sweep', + bounds.extent + ); + if (!claimVoxel) return; + + const { extent } = bounds; + const before = run.originalScalars; + const after = run.processedScalars; + const [ni, nj, nk] = extentSize(extent); + // Rows flat in one loop, as the mask sweeps are: a voxel's parent index is + // then a step along i from the row's own start, not a divide per voxel. + for (let row = 0; row < nj * nk; row += 1) { + const j = extent[2] + (row % nj); + const k = extent[4] + Math.floor(row / nj); + const from = row * ni; + for (let n = 0; n < ni; n += 1) { + const turnedOn = + after[from + n] !== LABELMAP_BACKGROUND_VALUE && + before[from + n] === LABELMAP_BACKGROUND_VALUE; + if (turnedOn && !claimVoxel.claim(extent[0] + n, j, k)) { + after[from + n] = LABELMAP_BACKGROUND_VALUE; + } + } + } + } + + /** + * One segment's preview slot, or nothing when the algorithm changed nothing + * or its storage went away while the algorithm ran. Both arrays are placed + * on the same extent before storage grows, so toggling or cancelling also + * restores voxels outside the input allocation. An algorithm must return a + * separate buffer: aliasing storage would erase its result on the first toggle. + */ + function buildRun( + target: ResolvedTarget, + originalScalars: TypedArray, + result: ProcessResult | undefined + ): PreviewRun[] { + if (result === undefined) return []; + if (!target.voxels.exists()) return []; + if (result.scalars === target.voxels.scalars()) { + throw new Error('Process returned the storage buffer it was given'); + } + const extent = previewExtent(target, result); + const binding = segmentationStore.findMaskBinding(target.maskId); + if ( + !binding?.extent.every((value, axis) => value === target.maskExtent[axis]) + ) + return []; + return [ + { + target, + extent, + originalScalars: extent.every( + (value, axis) => value === target.maskExtent[axis] + ) + ? originalScalars + : reframeMaskScalars(originalScalars, target.maskExtent, extent), + processedScalars: extent.every( + (value, axis) => value === result.extent[axis] + ) + ? result.scalars + : reframeMaskScalars(result.scalars, result.extent, extent), + }, + ]; + } + + function confirmProcess() { + if (cancelIfLocked()) return; + const state = processState.value; + // Apply commits the processed result. When the user is viewing the + // original, the masks currently hold originalScalars, so restore the + // processed scalars before finishing or the result is silently discarded. + if (state.step === 'previewing' && state.showingOriginal) { + state.runs.forEach((run) => + writeIfPresent(run.target.voxels, run.processedScalars) + ); + } + resetState(); + paintStore.restoreModeAfterProcess(); + } + + const segmentationStore = useSegmentationStore(); + const segmentRegistry = useSegmentStore().segments; + const imageCacheStore = useImageCacheStore(); + const paintStore = usePaintToolStore(); + const messageStore = useMessageStore(); + const { currentImageID } = useCurrentImage('global'); + + function cancelProcess() { + const state = processState.value; + + if (state.step === 'previewing') { + state.runs.forEach((run) => + writeIfPresent(run.target.voxels, run.originalScalars) + ); + } + // A run still computing has jobs sitting in the workers, one of which is + // running now and cannot be called back. Their results are already + // discarded, so the worker goes with them: the run that replaces this one + // starts on a fresh worker instead of waiting behind abandoned work. + if (state.step === 'computing') terminateProcessWorkers(); + resetState(); + paintStore.restoreModeAfterProcess(); + } + + // Locking any target cancels the whole uncommitted transaction. Rollback + // restores the original contents even though further edits are now locked. + const targetLocked = computed(() => { + const state = processState.value; + return ( + state.step !== 'start' && + state.targetMaskIds.some((maskId) => segmentationStore.isLocked(maskId)) + ); + }); + + function cancelIfLocked() { + if (!targetLocked.value) return false; + cancelProcess(); + return true; + } + + // Synchronous invalidation also honors a lock/unlock before Vue's next flush. + watch(targetLocked, cancelIfLocked, { flush: 'sync' }); + + function setActiveProcessType(processType: ProcessType) { + // Cancel any active process before switching + cancelProcess(); + activeProcessType.value = processType; + } + + // Distinguishes "every target's mask vanished mid-run" from "the algorithm + // looked and found nothing", which alone is worth telling the user about. + function warnIfNothingProcessed( + processType: ProcessType, + outputs: Awaited>[] + ) { + if (!outputs.every((output) => output === undefined)) return; + messageStore.addWarning( + `${processType} had nothing to do`, + 'No segment has anything on this slice. Scroll to a slice a segment covers, then try again.' + ); + } + + function targetFor(parentImageId: string, maskId: string) { + const parent = segmentationStore.getSegmentationForImage(parentImageId); + const voxels = segmentationStore.maskVoxels(maskId); + const binding = voxels.binding(); + const image = parent && imageCacheStore.getVtkImageData(parentImageId); + if ( + !binding || + isEmptyExtent(binding.extent) || + !voxels.exists() || + !image + ) { + return undefined; + } + return { + parentImageId, + parentDimensions: [...image.getDimensions()] as [number, number, number], + parentOrigin: Array.from(image.getOrigin()), + maskId, + voxels, + scalars: voxels.snapshot(), + dimensions: [...binding.image.getDimensions()] as [ + number, + number, + number, + ], + spacing: [...binding.image.getSpacing()] as [number, number, number], + direction: Array.from(binding.image.getDirection()), + maskExtent: [...binding.extent] as Extent3D, + labelValue: SEGMENT_VALUE, + } satisfies ResolvedTarget; + } + + function resolveSegmentScoped(imageId: string): ResolvedRun | undefined { + const maskId = segmentationStore.findEditTarget(imageId); + if (!maskId) { + messageStore.addError('No active segment selected'); + return undefined; + } + if (segmentationStore.isLocked(maskId)) { + messageStore.addError('Cannot process locked segment'); + return undefined; + } + const target = targetFor(imageId, maskId); + if (!target) { + messageStore.addError('No segment content to process'); + return undefined; + } + return { + targets: [target], + watchedMaskId: maskId, + }; + } + + // An image with segments on it and nothing editable is refusing for a reason + // the user can act on, so it does not get the empty image's message. + function nothingEditable(imageId: string) { + const masks = segmentationStore.imageMasks(imageId); + if (masks.length === 0) return 'No segmentation to process'; + return masks.every((mask) => segmentationStore.isLocked(mask.id)) + ? 'Every segment is locked' + : 'No unlocked segment has anything to process'; + } + + // All-segments: one run per editable segment, each on its own bounded mask. + // Nothing is created, and an image with no editable segment has nothing to + // process. + function resolveEverySegment(imageId: string): ResolvedRun | undefined { + const targets = segmentationStore + .editableMasks(imageId) + .flatMap(({ maskId }) => { + const target = targetFor(imageId, maskId); + return target ? [target] : []; + }); + if (targets.length === 0) { + messageStore.addError(nothingEditable(imageId)); + return undefined; + } + // No watched segment: the selection is not part of the target, so moving + // off it is not a reason to throw the run away. + return { targets }; + } + + // A process holding storage the user can still cancel out of. + const runInFlight = () => { + const state = processState.value; + return state.step === 'computing' || state.step === 'previewing' + ? state + : undefined; + }; + + // A run the user has already moved past: another process started, or the + // state machine left the step this one is finishing. + const runIsStale = (processRunId: number) => + processRunId !== activeProcessRunId || + processState.value.step !== 'computing'; + + async function startProcess( + algorithm: ProcessAlgorithm, + options?: { requiresActiveSegment?: boolean } + ) { + // Most processes operate on the active segment; all-segments processes opt + // out so they are not blocked by a locked active segment. + const imageId = currentImageID.value; + if (!imageId) { + messageStore.addError('No image to process'); + return; + } + + edits.beforeEdit(); + + const resolveRun = + options?.requiresActiveSegment === false + ? resolveEverySegment + : resolveSegmentScoped; + const resolved = resolveRun(imageId); + if (!resolved) return; + const { targets, watchedMaskId } = resolved; + + const processType = activeProcessType.value; + const processRunId = ++activeProcessRunId; + + const snapshots = targets.map((target) => target.scalars); + let runs: PreviewRun[] = []; + + const targetedState = { + activeParentImageID: imageId, + watchedMaskId, + targetMaskIds: targets.map((target) => target.maskId), + }; + edits.hold(cancelProcess); + paintStore.enterProcessMode(); + processState.value = { step: 'computing', ...targetedState }; + + try { + // Started together, so every algorithm reads its own mask before any + // result is written back: each run sees the state the user acted on. + const outputs = await Promise.all( + targets.map((input) => + algorithm({ + parentImageId: input.parentImageId, + maskId: input.maskId, + labelValue: input.labelValue, + scalars: input.scalars.slice(), + maskExtent: [...input.maskExtent], + dimensions: [...input.dimensions], + spacing: [...input.spacing], + direction: [...input.direction], + parentOrigin: [...input.parentOrigin], + parentDimensions: [...input.parentDimensions], + }) + ) + ); + + if (runIsStale(processRunId) || cancelIfLocked()) return; + + runs = targets.flatMap((target, index) => + buildRun(target, snapshots[index], outputs[index]) + ); + + // No segment came back with anything to write: every mask was deleted + // while the algorithm ran, or the algorithm had nothing to do to any of + // them. There is then nothing to preview and nothing to roll back. + if (runs.length === 0) { + warnIfNothingProcessed(processType, outputs); + resetState(); + paintStore.restoreModeAfterProcess(); + return; + } + + // Masked against what the segments hold now, so a run that reaches a + // voxel an earlier run of the same pass just filled leaves it there. + runs.forEach((run) => { + run.target.voxels.ensureContains(run.extent); + maskVoxelsOtherSegmentsHold(run); + run.target.voxels.apply(run.processedScalars); + }); + + processState.value = { + step: 'previewing', + ...targetedState, + runs, + showingOriginal: false, + }; + } catch (error) { + if (runIsStale(processRunId)) return; + + messageStore.addError(`${processType} Operation Failed`, { + error: error as Error, + }); + targets.forEach((target, index) => { + const run = runs.find((candidate) => candidate.target === target); + const binding = segmentationStore.findMaskBinding(target.maskId); + const grown = + run && + binding?.extent.every((value, axis) => value === run.extent[axis]); + writeIfPresent( + target.voxels, + grown ? run.originalScalars : snapshots[index] + ); + }); + resetState(); + paintStore.restoreModeAfterProcess(); + } + } + + /** + * Shows the original or the processed result, stated rather than flipped: + * the buttons that offer the two name the one they show, so re-clicking the + * one already showing has to leave the preview alone. + */ + function setShowingOriginal(showOriginal: boolean) { + if (cancelIfLocked()) return; + const state = processState.value; + + if (state.step === 'previewing' && state.showingOriginal !== showOriginal) { + state.runs.forEach((run) => + writeIfPresent( + run.target.voxels, + showOriginal ? run.originalScalars : run.processedScalars + ) + ); + + processState.value = { + ...state, + showingOriginal: showOriginal, + }; + } + } + + function togglePreview() { + const state = processState.value; + setShowingOriginal( + state.step === 'previewing' ? !state.showingOriginal : false + ); + } + + watch( + () => paintStore.activeMode, + (mode, previousMode) => { + if (previousMode !== PaintMode.Process || mode === PaintMode.Process) { + return; + } + if (!runInFlight()) return; + cancelProcess(); + } + ); + + // A preview belongs to the paint tool: putting the brush down hands the + // segment to another tool, which is free to grow the mask the preview holds + // a snapshot of. + watch( + () => paintStore.isActive, + (isActive) => { + if (isActive || !runInFlight()) return; + cancelProcess(); + } + ); + + // A preview holds a snapshot of storage another action can delete under it, + // so it does not outlive what it would write back into. Storage is its own + // matter: an all-segments run watches no segment, and a segment-scoped one is + // not the only way to lose a mask. + const previewStorageGone = computed(() => { + const state = processState.value; + if (state.step !== 'previewing') return false; + return state.runs.some((run) => !run.target.voxels.exists()); + }); + + watch(previewStorageGone, (gone) => { + if (gone) cancelProcess(); + }); + + // A segment-scoped run belongs to the type it was started on, so selecting + // another throws it away. An all-segments run watches nothing and outlives + // the selection changing under it. + watch( + () => segmentRegistry.selectedSegmentId.value, + (segmentId) => { + const state = runInFlight(); + if (!state) return; + const watched = state.watchedMaskId; + if (watched === undefined) return; + if ( + segmentationStore.maskExists(watched) && + segmentationStore.getMask(watched).segmentId === segmentId + ) + return; + cancelProcess(); + } + ); + + // Cancel process when current image changes + watch(currentImageID, (newVal) => { + const state = runInFlight(); + if (state && state.activeParentImageID !== newVal) cancelProcess(); + }); + + return { + processState, + processStep, + activeProcessType, + showingOriginal, + setActiveProcessType, + startProcess, + confirmProcess, + cancelProcess, + setShowingOriginal, + togglePreview, + }; +}); diff --git a/src/segmentation/editing/processWorker.ts b/src/segmentation/editing/processWorker.ts new file mode 100644 index 000000000..c8006a82d --- /dev/null +++ b/src/segmentation/editing/processWorker.ts @@ -0,0 +1,93 @@ +import * as Comlink from 'comlink'; + +/** + * A process algorithm's worker, kept warm between runs. + * + * Comlink settles a call when the worker posts an answer back, and listens for + * nothing else. A worker that fails to load its module chunk, or that the + * browser kills, therefore answers nothing and the call waits forever: the + * process sits in `computing` with no error to show and no storage rolled + * back, and every later run reuses the same dead instance. The host watches + * the worker itself, so a worker that dies takes the calls in flight down with + * it and is dropped, leaving the next call to start a fresh one. + */ +export type ProcessWorkerHost = { + // `Awaited` rather than a `Promise` parameter: a Comlink method whose + // return type is a union hands back a union of promises, and the result + // type has to survive that. + 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()); +} + +/** What the worker said, or which event ended it when it said nothing. */ +function workerFailure(event: Event) { + const reported = (event as Partial).message; + return new Error( + reported || `The worker running this process reported "${event.type}".` + ); +} + +export function createProcessWorkerHost( + spawn: () => Worker +): ProcessWorkerHost { + let live: { + proxy: Comlink.Remote; + died: Promise; + discard: (reason: Error) => void; + } | null = null; + + function start() { + const worker = spawn(); + const proxy = Comlink.wrap(worker); + let end: (reason: Error) => void = () => {}; + const died = new Promise((_resolve, reject) => { + end = reject; + }); + const discard = (reason: Error) => { + // Only this worker's own end drops the cache: a later run may already + // have started its replacement. + if (live?.proxy === proxy) live = null; + worker.terminate(); + end(reason); + }; + const die = (event: Event) => discard(workerFailure(event)); + worker.addEventListener('error', die); + worker.addEventListener('messageerror', die); + // Calls in flight see the rejection through `call`; a worker dropped with + // nothing running is not a failure anyone is waiting on. + died.catch(() => undefined); + live = { proxy, died, discard }; + return live; + } + + async function call( + use: (api: Comlink.Remote) => T + ): Promise> { + const instance = live ?? start(); + return Promise.race([use(instance.proxy), instance.died]) as Promise< + Awaited + >; + } + + const host = { + call, + terminate: () => + live?.discard(new Error('The process worker was stopped.')), + }; + hosts.add(host); + return host; +} diff --git a/src/segmentation/editing/rasterizePolygon.ts b/src/segmentation/editing/rasterizePolygon.ts new file mode 100644 index 000000000..89623aa42 --- /dev/null +++ b/src/segmentation/editing/rasterizePolygon.ts @@ -0,0 +1,195 @@ +import { fillPoly } from '@thi.ng/rasterize'; +import type { IGrid2D } from '@thi.ng/api'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { TypedArray, Vector2, Vector3 } from '@kitware/vtk.js/types'; +import { containsPoint } from '@kitware/vtk.js/Common/DataModel/BoundingBox'; + +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useMessageStore } from '@/src/store/messages'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import type { Maybe } from '@/src/types'; +import type { LPSAxis } from '@/src/types/lps'; +import { + clipExtent, + emptyExtent, + fullExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { getLPSDirections } from '@/src/utils/lps'; + +/** + * The labelmap a polygon rasterizes into, absent when the record it lands in + * is locked. Rasterizing is itself an edit, so it routes through the one entry + * point that resolves and creates masks: a polygon carrying no segment, or one + * whose segment was deleted, lands in the selected segment rather than failing. + */ +export function resolveRasterizeTarget( + imageId: string, + segmentId: Maybe +) { + const segmentationStore = useSegmentationStore(); + + // A locked segment is not editable, the same refusal paint and the processes + // make. Asked of the segment before the target is resolved, since resolving + // mints the mask record and its segmentation: a refused polygon leaves + // neither behind. A locked neighbour is a different rule and keeps the voxels + // a fill claims, which an aimed `voxelClaim` already honours. + if (segmentationStore.editTargetLocked(segmentId)) { + useMessageStore().addError('Cannot rasterize into a locked segment'); + return undefined; + } + + const resolved = segmentationStore.resolveEditTarget(imageId, segmentId); + const voxels = segmentationStore.maskVoxels(resolved); + // The binding's extent goes stale the moment the fill grows the mask, so + // the accessor is what travels, not anything read off it now. + voxels.materialize(); + return { + labelValue: SEGMENT_VALUE, + voxels, + maskId: resolved, + segmentId: segmentationStore.getMask(resolved).segmentId, + }; +} + +function createGridAccessor( + image: vtkImageData, + pixelData: TypedArray, + plane: { slice: number; axisIdx: 0 | 1 | 2 }, // i/j/k + onFilled: (ijk: Vector3) => void +): IGrid2D { + const { slice, axisIdx } = plane; + const axisDims = image.getDimensions(); + axisDims.splice(axisIdx, 1); + const extent = image.getExtent(); + const convertTo3D = (a: number, b: number) => { + const point = [a, b]; + point.splice(axisIdx, 0, slice); + return point as Vector3; + }; + + return { + size: axisDims, + setAtUnsafe(d0: number, d1: number, value: number): boolean { + const ijk = convertTo3D(d0, d1); + if (containsPoint(extent, ...ijk)) { + const offset = image.computeOffsetIndex(ijk); + // XXX assumes single-component image + pixelData[offset] = value; + onFilled(ijk); + return true; + } + return false; + }, + } as unknown as IGrid2D; +} + +/** The box the polygon spans on its slice, in parent index space. */ +function polygonBounds( + indexPoints: number[][], + axisIndex: 0 | 1 | 2, + slice: number +): Extent3D { + if (indexPoints.length === 0) return emptyExtent(); + const bounds = [0, 0, 0, 0, 0, 0] as Extent3D; + [0, 1, 2].forEach((axis) => { + if (axis === axisIndex) { + bounds[axis * 2] = slice; + bounds[axis * 2 + 1] = slice; + return; + } + const values = indexPoints.map((point) => point[axis]); + bounds[axis * 2] = Math.floor(Math.min(...values)); + bounds[axis * 2 + 1] = Math.ceil(Math.max(...values)); + }); + return bounds; +} + +/** + * Fills a polygon into its segment's mask. The write lives here rather than in + * the tool component because it is a voxel operation: the mask has to grow to + * hold the polygon before `fillPoly` runs, since a mask that does not reach a + * pixel swallows it silently, and the filled voxels have to be cleared in the + * other segments of the image. World points, parent slice index. Edit target resolution cancels any competing preview before storage changes. + */ +export function rasterizePolygon({ + imageId, + segmentId, + points, + slice, + viewAxis, +}: { + imageId: string; + segmentId: Maybe; + points: Vector3[]; + slice: number; + viewAxis: LPSAxis; +}) { + const segmentationStore = useSegmentationStore(); + const parent = useImageCacheStore().getVtkImageData(imageId); + if (!parent) throw new Error('No such parent image'); + + const axisIndex = getLPSDirections(parent.getDirection())[viewAxis]; + const indexPoints = points.map((point) => [...parent.worldToIndex(point)]); + + // The part of the image the polygon lands on: what the mask has to grow to + // hold, and the only place this fill can take a voxel from a neighbour. + // Asked before the target is resolved, since resolving mints the mask record + // and its storage: a polygon covering nothing leaves neither behind. + const polygonExtent = clipExtent( + polygonBounds(indexPoints, axisIndex, slice), + fullExtent(parent.getDimensions()) + ); + if (isEmptyExtent(polygonExtent)) return { segmentId, maskId: undefined }; + + // A refusal names the segment it was given and no mask: nothing was written. + const target = resolveRasterizeTarget(imageId, segmentId); + if (!target) return { segmentId, maskId: undefined }; + + target.voxels.ensureContains(polygonExtent); + + // Copied out of the reactive tree: the claim below runs per filled pixel. + const extent = [...target.voxels.binding()!.extent] as Extent3D; + if (isEmptyExtent(extent)) + return { segmentId: target.segmentId, maskId: target.maskId }; + const points2D = indexPoints.map((point) => { + const local = [ + point[0] - extent[0], + point[1] - extent[2], + point[2] - extent[4], + ]; + local.splice(axisIndex, 1); + return local as Vector2; + }); + + // A polygon is aimed at a place, so filling it takes the voxel. Scoped to + // the polygon rather than the whole mask: a neighbour the polygon does not + // reach has nothing here to give up, and it would be walked per filled pixel. + const claimVoxel = segmentationStore.voxelClaim( + target.maskId, + 'aimed', + polygonExtent + ); + const mask = target.voxels.image(); + const grid = createGridAccessor( + mask, + target.voxels.scalars(), + { slice: slice - extent[axisIndex * 2], axisIdx: axisIndex }, + (ijk) => + claimVoxel?.claim( + ijk[0] + extent[0], + ijk[1] + extent[2], + ijk[2] + extent[4] + ) + ); + + try { + fillPoly(grid, points2D, target.labelValue); + } finally { + claimVoxel?.finish(); + mask.modified(); + } + return { segmentId: target.segmentId, maskId: target.maskId }; +} diff --git a/src/segmentation/geometry.ts b/src/segmentation/geometry.ts new file mode 100644 index 000000000..7fdca962f --- /dev/null +++ b/src/segmentation/geometry.ts @@ -0,0 +1,177 @@ +/** vtk.js index-space extent order: [iMin, iMax, jMin, jMax, kMin, kMax]. */ +export type Extent3D = [number, number, number, number, number, number]; + +/** Widens `box` in place to take in one more index. */ +export const growExtent = (box: Extent3D, i: number, j: number, k: number) => { + box[0] = Math.min(box[0], i); + box[1] = Math.max(box[1], i); + box[2] = Math.min(box[2], j); + box[3] = Math.max(box[3], j); + box[4] = Math.min(box[4], k); + box[5] = Math.max(box[5], k); +}; + +export function emptyExtent(): Extent3D { + return [0, -1, 0, -1, 0, -1]; +} + +/** vtk.js extents are inclusive, so an axis is empty only when max < min. */ +export function isEmptyExtent(extent: Extent3D) { + return ( + extent[1] < extent[0] || extent[3] < extent[2] || extent[5] < extent[4] + ); +} + +/** Voxel counts along i, j, k. Meaningless for an empty extent. */ +export function extentSize(extent: Extent3D) { + return [ + extent[1] - extent[0] + 1, + extent[3] - extent[2] + 1, + extent[5] - extent[4] + 1, + ] as [number, number, number]; +} + +export function extentContains(outer: Extent3D, inner: Extent3D) { + return ( + inner[0] >= outer[0] && + inner[1] <= outer[1] && + inner[2] >= outer[2] && + inner[3] <= outer[3] && + inner[4] >= outer[4] && + inner[5] <= outer[5] + ); +} + +export function extentContainsIndex( + extent: Extent3D, + i: number, + j: number, + k: number +) { + return ( + i >= extent[0] && + i <= extent[1] && + j >= extent[2] && + j <= extent[3] && + k >= extent[4] && + k <= extent[5] + ); +} + +/** A mask's extent with the row and plane strides that extent implies. */ +export type MaskBounds = { + extent: Extent3D; + mi: number; + mj: number; +}; + +/** Where a parent-index voxel sits in the buffer of a mask bounded that way. */ +export const maskOffset = ( + bounds: MaskBounds, + i: number, + j: number, + k: number +) => + i - + bounds.extent[0] + + (j - bounds.extent[2]) * bounds.mi + + (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. + */ +export function markedExtent( + scalars: ArrayLike, + extent: Extent3D, + labelValue: number +): Extent3D { + const ni = extent[1] - extent[0] + 1; + const nj = extent[3] - extent[2] + 1; + const nk = extent[5] - extent[4] + 1; + 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; + const i = extent[0] + index; + if (bounds) growExtent(bounds, i, j, k); + else bounds = [i, i, j, j, k, k]; + } + }; + + for (let row = 0; row < nj * nk; row += 1) { + scanRow(row * ni, extent[2] + (row % nj), extent[4] + Math.floor(row / nj)); + } + + return bounds ?? emptyExtent(); +} + +/** The parent-image slice indices containing `labelValue`, for i, j and k. */ +export function markedSlices( + scalars: ArrayLike, + extent: Extent3D, + labelValue: number +): [number[], number[], number[]] { + const ni = extent[1] - extent[0] + 1; + const nj = extent[3] - extent[2] + 1; + const occupied = [new Set(), new Set(), new Set()]; + + for (let offset = 0; offset < scalars.length; offset += 1) { + if (scalars[offset] !== labelValue) continue; + const i = extent[0] + (offset % ni); + const row = Math.floor(offset / ni); + const j = extent[2] + (row % nj); + const k = extent[4] + Math.floor(row / nj); + occupied[0].add(i); + occupied[1].add(j); + occupied[2].add(k); + } + + return occupied.map((slices) => [...slices]) as [ + number[], + number[], + number[], + ]; +} + +export function extentUnion(a: Extent3D, b: Extent3D): Extent3D { + return [ + Math.min(a[0], b[0]), + Math.max(a[1], b[1]), + Math.min(a[2], b[2]), + Math.max(a[3], b[3]), + Math.min(a[4], b[4]), + Math.max(a[5], b[5]), + ]; +} + +/** `extent` widened by `padding` voxels on every face. */ +export function padExtent(extent: Extent3D, padding: number): Extent3D { + return [ + extent[0] - padding, + extent[1] + padding, + extent[2] - padding, + extent[3] + padding, + extent[4] - padding, + extent[5] + padding, + ]; +} + +/** The part of `extent` inside `bounds`; empty when they do not overlap. */ +export function clipExtent(extent: Extent3D, bounds: Extent3D): Extent3D { + return [ + Math.max(extent[0], bounds[0]), + Math.min(extent[1], bounds[1]), + Math.max(extent[2], bounds[2]), + Math.min(extent[3], bounds[3]), + Math.max(extent[4], bounds[4]), + Math.min(extent[5], bounds[5]), + ]; +} + +/** The extent of an image's whole index space. */ +export function fullExtent(dimensions: number[] | Int32Array): Extent3D { + return [0, dimensions[0] - 1, 0, dimensions[1] - 1, 0, dimensions[2] - 1]; +} diff --git a/src/segmentation/io/__tests__/export.spec.ts b/src/segmentation/io/__tests__/export.spec.ts new file mode 100644 index 000000000..fbbb7a658 --- /dev/null +++ b/src/segmentation/io/__tests__/export.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import JSZip from 'jszip'; + +import { bundleExportFiles, layerFileName } from '@/src/segmentation/io/export'; + +// A labelmap file carries one label per voxel, so a segmentation with overlap +// leaves as several files. One file is the common case and stays the download +// it has always been: same name, same bytes, no archive around it. + +const bytes = (...values: number[]) => new Uint8Array(values); + +const readBlob = async (blob: Blob) => + Array.from(new Uint8Array(await blob.arrayBuffer())); + +describe('naming the file each group of segments writes', () => { + it('gives the first group the plain name', () => { + expect(layerFileName('Prostate', 'seg.nrrd', 0)).toBe('Prostate.seg.nrrd'); + }); + + it('numbers every later group after it', () => { + expect(layerFileName('Prostate', 'seg.nrrd', 1)).toBe( + 'Prostate_layer1.seg.nrrd' + ); + expect(layerFileName('Prostate', 'nii.gz', 2)).toBe( + 'Prostate_layer2.nii.gz' + ); + }); +}); + +describe('handing the written files to the browser', () => { + it('downloads a single file as itself', async () => { + const bundle = await bundleExportFiles('Prostate', [ + { name: 'Prostate.seg.nrrd', data: bytes(1, 2, 3) }, + ]); + + expect(bundle.name).toBe('Prostate.seg.nrrd'); + expect(await readBlob(bundle.blob)).toEqual([1, 2, 3]); + }); + + it('downloads several files as one archive of them', async () => { + const bundle = await bundleExportFiles('Prostate', [ + { name: 'Prostate.seg.nrrd', data: bytes(1, 2, 3) }, + { name: 'Prostate_layer1.seg.nrrd', data: bytes(4, 5) }, + ]); + + expect(bundle.name).toBe('Prostate.zip'); + const zip = await JSZip.loadAsync(bundle.blob); + expect(Object.keys(zip.files)).toEqual([ + 'Prostate.seg.nrrd', + 'Prostate_layer1.seg.nrrd', + ]); + expect( + Array.from( + await zip.files['Prostate_layer1.seg.nrrd'].async('uint8array') + ) + ).toEqual([4, 5]); + }); +}); diff --git a/src/segmentation/io/__tests__/labelmap.spec.ts b/src/segmentation/io/__tests__/labelmap.spec.ts new file mode 100644 index 000000000..b72ee8b1e --- /dev/null +++ b/src/segmentation/io/__tests__/labelmap.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { + allocateLabelmap, + labelmapScalars, + normalizeLabelmapScalars, +} from '../labelmap'; + +describe('labelmap interchange storage', () => { + it.each([255, 256, 65535])('stores label %i without truncation', (count) => { + const parent = vtkImageData.newInstance({ + spacing: [2, 3, 4], + origin: [5, 6, 7], + }); + parent.setDimensions(2, 2, 2); + const image = allocateLabelmap(parent, count); + const values = labelmapScalars(image); + values[7] = count; + expect(values[7]).toBe(count); + expect(values.BYTES_PER_ELEMENT).toBe(count <= 255 ? 1 : 2); + expect(image.indexToWorld([1, 1, 1])).toEqual( + parent.indexToWorld([1, 1, 1]) + ); + }); + + it('rejects a single-file export beyond 16-bit capacity', () => { + expect(() => allocateLabelmap(vtkImageData.newInstance(), 65536)).toThrow( + 'at most 65535' + ); + }); + + it('keeps high input values and excludes invalid values without wrapping', () => { + const input = new Float32Array([ + 0, + 1, + 255, + 256, + 65535, + -1, + 65536, + NaN, + Infinity, + ]); + const values = normalizeLabelmapScalars(input); + expect(values).toBeInstanceOf(Uint16Array); + expect(Array.from(values)).toEqual([0, 1, 255, 256, 65535, 0, 0, 0, 0]); + expect(input[6]).toBe(65536); + }); + + it('normalizes a wide binary input to byte mask storage', () => { + expect(normalizeLabelmapScalars(new Uint16Array([0, 1]))).toEqual( + new Uint8Array([0, 1]) + ); + }); + + it('excludes invalid values from a plain number array', () => { + const values = normalizeLabelmapScalars([ + 0, + 3, + -2, + NaN, + Infinity, + -Infinity, + 7, + ]); + expect(values).toBeInstanceOf(Uint8Array); + expect(Array.from(values)).toEqual([0, 3, 0, 0, 0, 0, 7]); + }); +}); diff --git a/src/segmentation/io/composition.ts b/src/segmentation/io/composition.ts new file mode 100644 index 000000000..f0bef8163 --- /dev/null +++ b/src/segmentation/io/composition.ts @@ -0,0 +1,121 @@ +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +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 { groupByLayer, writeMaskInto } from '@/src/segmentation/masks/overlap'; +import { boundedMask } from '@/src/segmentation/masks/voxelAccess'; +import { + allocateLabelmap, + labelmapScalars, + LABELMAP_MAX_VALUE, +} from '@/src/segmentation/io/labelmap'; +import { type SegmentMask } from '@/src/segmentation/model'; +import { toLabelmapSegment } from '@/src/segmentation/segment'; + +/** + * 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 snapshot = captureLabelmapParts(parentImageId, [ + members ?? useSegmentationStore().imageMasks(parentImageId), + ]); + return composeLabelmapPart(snapshot.parent, snapshot.parts[0]); +} + +/** Snapshot bounded geometry and appearance without allocating full-volume parts. */ +export function captureLabelmapParts( + parentImageId: string, + parts: SegmentMask[][] +) { + const source = useImageCacheStore().getVtkImageData(parentImageId); + if (!source) throw new Error('No such parent image'); + const parent = vtkImageData.newInstance({ + origin: [...source.getOrigin()], + spacing: [...source.getSpacing()], + direction: [...source.getDirection()], + }); + parent.setDimensions(source.getDimensions()); + parent.computeTransforms(); + const registry = useSegmentStore().segments; + return { + parent, + parts: parts.map((part) => + [...part] + .sort( + (a, b) => + registry.orderIndexOf(a.segmentId) - + registry.orderIndexOf(b.segmentId) + ) + .map((mask, index) => { + const bounded = boundedMask(mask.representations.labelmap); + return { + descriptor: toLabelmapSegment( + registry.getSegment(mask.segmentId), + index + 1 + ), + bounded: bounded && { + ...bounded, + scalars: bounded.scalars.slice(), + }, + }; + }) + ), + }; +} + +type CapturedPart = ReturnType['parts'][number]; + +export function composeLabelmapPart(parent: vtkImageData, part: CapturedPart) { + const labelmap = allocateLabelmap(parent, part.length); + const values = labelmapScalars(labelmap); + for (const { descriptor, bounded } of [...part].reverse()) { + if (bounded) + writeMaskInto(values, parent.getDimensions(), bounded, descriptor.value); + } + return { labelmap, segments: part.map(({ descriptor }) => descriptor) }; +} + +/** Plan overlap-free files and retain why more than one file is necessary. */ +export function planLabelmapExport( + parentImageId: string, + preferredSegmentId?: string +) { + const registry = useSegmentStore().segments; + const masks = [...useSegmentationStore().imageMasks(parentImageId)].sort( + (a, b) => { + if (a.segmentId === preferredSegmentId) return -1; + if (b.segmentId === preferredSegmentId) return 1; + return ( + registry.orderIndexOf(a.segmentId) - registry.orderIndexOf(b.segmentId) + ); + } + ); + const layers = groupByLayer(masks, (segment) => + boundedMask(segment.representations.labelmap) + ); + const parts = layers.flatMap((layer) => + Array.from( + { length: Math.max(1, Math.ceil(layer.length / LABELMAP_MAX_VALUE)) }, + (_, index) => + layer.slice( + index * LABELMAP_MAX_VALUE, + (index + 1) * LABELMAP_MAX_VALUE + ) + ) + ); + return { + parts: parts.length ? parts : [[]], + hasOverlap: layers.length > 1, + exceedsCapacity: layers.some((layer) => layer.length > LABELMAP_MAX_VALUE), + }; +} diff --git a/src/segmentation/io/export.ts b/src/segmentation/io/export.ts new file mode 100644 index 000000000..17b3add4d --- /dev/null +++ b/src/segmentation/io/export.ts @@ -0,0 +1,36 @@ +import JSZip from 'jszip'; + +/** One written file on its way to the browser. */ +export type ExportFile = { + name: string; + data: string | Uint8Array; +}; + +/** + * The name a group's file carries. The first keeps the plain stem, so a + * segmentation with no overlap saves as the one file it always did. + */ +export const layerFileName = (stem: string, format: string, layer: number) => + layer === 0 ? `${stem}.${format}` : `${stem}_layer${layer}.${format}`; + +/** The archive every file of one save is bundled into. */ +export const archiveNameFor = (stem: string) => `${stem}.zip`; + +/** + * What a save hands to the browser: the single file itself, or every file in + * one archive. A labelmap file carries one label per voxel, so segments that + * overlap cannot share one and the save turns into several. + */ +export async function bundleExportFiles(stem: string, files: ExportFile[]) { + const [first] = files; + if (files.length === 1) { + return { name: first.name, blob: new Blob([first.data]) }; + } + + const zip = new JSZip(); + files.forEach((file) => zip.file(file.name, file.data)); + return { + name: archiveNameFor(stem), + blob: await zip.generateAsync({ type: 'blob' }), + }; +} diff --git a/src/segmentation/io/import.ts b/src/segmentation/io/import.ts new file mode 100644 index 000000000..bc2ce9404 --- /dev/null +++ b/src/segmentation/io/import.ts @@ -0,0 +1,347 @@ +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 } 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, + 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'; + +import { + labelmapScalars, + normalizeLabelmapScalars, + type LabelmapScalars, +} from '@/src/segmentation/io/labelmap'; + +/** A segment an import created, and the source label value it was split from. */ +export type ImportedSegment = { sourceValue: number; maskId: string }; + +export function toLabelMap(imageData: vtkImageData) { + const labelmap = vtkLabelMap.newInstance( + imageData.get('spacing', 'origin', 'direction', 'extent', 'dataDescription') + ); + + labelmap.setDimensions(imageData.getDimensions()); + labelmap.computeTransforms(); + + const source = imageData.getPointData().getScalars(); + const scalars = vtkDataArray.newInstance({ + numberOfComponents: source.getNumberOfComponents(), + values: normalizeLabelmapScalars(source.getData()), + }); + 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 } +>(); + +/** The box a label value occupies, per value, in one sweep of the buffer. */ +function labelValueBounds(labelmap: vtkLabelMap) { + const cached = boundsCache.get(labelmap); + if (cached?.mTime === labelmap.getMTime()) return cached.bounds; + + const scalars = labelmapScalars(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: LabelmapScalars; + 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); +} + +/** Storage for one descriptor's segment, minted by the caller. */ +export type MaskMinter = ( + descriptor: LabelmapSegment, + extent: Extent3D +) => { labelValue: number; mask: Uint8Array }; + +/** + * One bounded mask per label value. An imported or legacy labelmap carries + * every segment in one buffer; each descriptor gets a mask cropped to the box + * that value's voxels span, filled with the value the minter assigned it. + */ +export function splitLabelmap( + labelmap: vtkLabelMap, + descriptors: LabelmapSegment[], + mint: MaskMinter +) { + const scalars = labelmapScalars(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, + }) + ); +} + +/** Distinct nonzero voxel values, ascending: the segment spine. */ +const distinctLabelValues = (image: vtkLabelMap) => + [...labelValueBounds(image).keys()].sort((first, second) => first - second); + +export type DecodeOptions = { + /** Which component of a multi-component DICOM-SEG to read descriptors from. */ + component?: number; + /** File-header metadata, for bytes that never came through a loaded image. */ + headerMetadata?: Map; + /** What undescribed segments are named after, in place of 'Segment'. */ + baseName?: string; + nextColor: () => readonly number[]; +}; + +/** A lone value carries the base name bare: there is nothing to tell apart. */ +const fallbackNamer = (values: number[], baseName?: string) => { + if (!baseName) return makeDefaultSegmentName; + if (values.length === 1) return () => baseName; + return (value: number) => `${baseName} ${value}`; +}; + +/** + * `imageId` may be undefined when the labelmap's bytes did not arrive through + * a loaded image dataset. DICOM-SEG decoding still + * requires a source image, while file-header metadata can be supplied directly + * for archive-backed images. + */ +export async function decodeLabelmapSegments( + imageId: DataSelection | undefined, + image: vtkLabelMap, + options: DecodeOptions +) { + const fromSegBuild = await segBuildDescriptors( + imageId, + options.component ?? 0 + ); + if (fromSegBuild) return fromSegBuild; + + // Slicer-convention `.seg.nrrd` embedded metadata: a labelmap produced by a + // backend CLI carries its real segment names/colors in the NRRD header, + // captured onto the loaded image at import. + // + // Overlay metadata so undescribed voxel values retain a default segment. + const embedded = + options.headerMetadata ?? + (imageId !== undefined + ? useImageCacheStore().imageById[imageId]?.headerMetadata + : undefined); + const described = embedded ? parseSegNrrdMetadata(embedded) : undefined; + + const values = distinctLabelValues(image); + const nameFor = fallbackNamer(values, options.baseName); + + return overlaySegmentMetadata(values, described, (value) => ({ + value, + name: nameFor(value), + color: [...options.nextColor()] as RGBAColor, + visible: true, + })); +} + +export type LabelmapImportHooks = { + /** + * Descriptors for one component. `componentCount` is how many components + * this image has in all, so a decode that adds a descriptor the voxels never + * carried can add it once, on the last component, instead of once per + * component -- a value can have voxels in one component and none in another. + */ + decode: ( + labelmap: vtkLabelMap, + component: number, + componentCount: number + ) => Promise; + /** Mints the segments for one decoded labelmap, in descriptor order. */ + split: (labelmap: vtkLabelMap, descriptors: LabelmapSegment[]) => string[]; +}; + +/** + * Resampling and decoding both yield, and an image can be removed while they + * run, so the parent is resolved through the cache again after every await: + * nothing may be decoded or minted against an image that left the scene. + */ +function requireParentImage(parentID: DataSelection) { + const parentImage = getImage(parentID); + if (!parentImage) throw new Error('Parent image is no longer loaded'); + return parentImage; +} + +/** + * Converts an image to a labelmap, one bounded mask per label value. + * + * Returns the segments created per component of the source image (one entry + * for the common single-component case), each paired with the source label + * value it was split from. A value already taken on the parent is remapped, so + * the source value is the only handle a caller's descriptors can match on. + */ +export async function importLabelmapImage( + imageID: DataSelection, + parentID: DataSelection, + hooks: LabelmapImportHooks +): Promise { + if (imageID === parentID) + throw new Error('Cannot convert an image to be a labelmap of itself'); + + await untilLoaded(imageID); + + const [childImage, parentImage] = await Promise.all( + [imageID, parentID].map(getImage) + ); + + if (!childImage || !parentImage) + throw new Error('Image and/or parent datasets do not exist'); + + const intersects = vtkBoundingBox.intersects( + parentImage.getBounds(), + childImage.getBounds() + ); + if (!intersects) { + throw new Error( + 'Imported image and parent image bounds do not intersect. So there is no overlap in physical space.' + ); + } + + const componentCount = childImage + .getPointData() + .getScalars() + .getNumberOfComponents(); + const images = + componentCount === 1 ? [childImage] : extractEachComponent(childImage); + + // Sequential, not fanned out: the splits share one segmentation, and label + // values are minted against the segments already in it. + const created: ImportedSegment[][] = []; + for (const [component, image] of images.entries()) { + const matchingParentSpace = await ensureSameSpace(parentImage, image, true); + requireParentImage(parentID); + const labelmapImage = toLabelMap(matchingParentSpace); + const descriptors = await hooks.decode( + labelmapImage, + component, + images.length + ); + requireParentImage(parentID); + created.push( + hooks.split(labelmapImage, descriptors).map((maskId, index) => ({ + sourceValue: descriptors[index].value, + maskId, + })) + ); + } + return created; +} diff --git a/src/segmentation/io/labelmap.ts b/src/segmentation/io/labelmap.ts new file mode 100644 index 000000000..57baa6ad0 --- /dev/null +++ b/src/segmentation/io/labelmap.ts @@ -0,0 +1,63 @@ +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { TypedArray } from '@kitware/vtk.js/types'; +import vtkLabelMap from '@/src/vtk/LabelMap'; +import { fullExtent } from '@/src/segmentation/geometry'; +import { placeMask } from '@/src/segmentation/masks/storage'; + +export const LABELMAP_MAX_VALUE = 65535; +const LABELMAP_BYTE_MAX_VALUE = 255; +export type LabelmapScalars = Uint8Array | Uint16Array; + +export const labelmapScalars = (image: vtkImageData) => + image.getPointData().getScalars().getData() as LabelmapScalars; + +const labelmapArrayType = (maxValue: number) => { + if (maxValue > LABELMAP_MAX_VALUE) { + throw new Error(`A labelmap holds at most ${LABELMAP_MAX_VALUE} segments`); + } + return maxValue > LABELMAP_BYTE_MAX_VALUE ? Uint16Array : Uint8Array; +}; + +/** Interchange storage has distinct labels; editable masks remain binary. */ +export function allocateLabelmap(parent: vtkImageData, count: number) { + const ArrayType = labelmapArrayType(count); + const image = vtkLabelMap.newInstance( + parent.get('spacing', 'origin', 'direction') + ); + const dimensions = placeMask( + image, + parent, + fullExtent(parent.getDimensions()) + ); + const values = new ArrayType(dimensions[0] * dimensions[1] * dimensions[2]); + image + .getPointData() + .setScalars(vtkDataArray.newInstance({ numberOfComponents: 1, values })); + return image; +} + +/** Reject unsupported values instead of wrapping them into another segment. */ +export function normalizeLabelmapScalars( + input: number[] | TypedArray +): LabelmapScalars { + if (input instanceof Uint8Array) return input; + // Both passes are hot over whole volumes, so they index the input directly + // and compare inline. NaN and the infinities fail every comparison below, + // which is what excludes them; a fresh typed array is already zeroed, so an + // excluded voxel needs no write. + const { length } = input; + let maximum = 0; + for (let index = 0; index < length; index += 1) { + const value = input[index]; + if (value > maximum && value <= LABELMAP_MAX_VALUE) maximum = value; + } + const ArrayType = labelmapArrayType(maximum); + if (input instanceof ArrayType) return input; + const values = new ArrayType(length); + for (let index = 0; index < length; index += 1) { + const value = input[index]; + if (value >= 0 && value <= LABELMAP_MAX_VALUE) values[index] = value; + } + return values; +} diff --git a/src/segmentation/io/maskFileNaming.ts b/src/segmentation/io/maskFileNaming.ts new file mode 100644 index 000000000..a9e01121e --- /dev/null +++ b/src/segmentation/io/maskFileNaming.ts @@ -0,0 +1,29 @@ +const defaultName = (baseName: string, index: number) => + `Segment Group ${index} for ${baseName}`; + +/** + * Default names for mask files, counted per parent image. The + * count keeps rising so a deleted mask's name is not immediately handed to + * the next one, and `taken` skips a name something already holds. + */ +export function createMaskFileNamer( + taken: () => { has: (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)); + return name; + }, + /** Called by the deletion cascade, so a removed image stops counting. */ + forget(parentImageId: string) { + delete nextIndex[parentImageId]; + }, + }; +} diff --git a/src/segmentation/io/restore.ts b/src/segmentation/io/restore.ts new file mode 100644 index 000000000..40dbeda49 --- /dev/null +++ b/src/segmentation/io/restore.ts @@ -0,0 +1,150 @@ +import { markRaw } from 'vue'; +import { until } from '@vueuse/core'; +import type { ProgressiveImage } from '@/src/core/progressiveImage'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import type { Segmentation } from '@/src/io/state-file/schema'; +import { placeMask, setMaskScalars } from '@/src/segmentation/masks/storage'; +import type { ProcessingResultSource } from '@/src/types'; +import { + LABELMAP_BACKGROUND_VALUE, + maskScalars, + type LabelmapBinding, +} from '@/src/segmentation/model'; +import { + extentContains, + extentSize, + fullExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { arrayEquals } from '@/src/utils'; +import type vtkLabelMap from '@/src/vtk/LabelMap'; + +export type WireMask = Segmentation['masks'][number]; + +/** Requires complete image data for an input's source or parent grid. */ +export function createLoadedImageReader( + getImage: (id: string) => ProgressiveImage | undefined, + getVtkImageData: (id: string) => vtkImageData | undefined +) { + return async (imageId: string) => { + // A stopped, incomplete load cannot supply the grid for an input. + // Removal also settles the watcher, including removal before it starts. + await until(() => !getImage(imageId)?.loading.value).toBe(true); + if (getImage(imageId)?.status.value !== 'complete') { + throw new Error('Labelmap image did not load'); + } + const image = getVtkImageData(imageId); + if (!image) throw new Error('Could not get input image data'); + return image; + }; +} + +/** One mask's labelmap, read back from the archive entry its binding named. */ +export type LoadedLabelmap = { + labelmap: vtkLabelMap; + name: string; + source?: ProcessingResultSource; +}; + +export type SkippedRestoreItem = { name: string; reason: string }; + +type RestoreBindingInput = { + manifest: { segmentations?: Segmentation[] }; + dataIDMap: Record; + /** + * What each mask's own archive entry held. A mask awaiting an input's + * split is absent: its voxels are still inside that input. + */ + loaded: Map; + getParentImage: (id: string) => vtkImageData | undefined; +}; + +const sameDimensions = (extent: Extent3D, dimensions: number[]) => + arrayEquals(extentSize(extent), dimensions); + +function validExtent( + extent: Extent3D, + labelmap: vtkLabelMap, + parentImage: vtkImageData, + reject: (reason: string) => void +) { + if (isEmptyExtent(extent)) { + const containsForeground = maskScalars(labelmap).some( + (value) => value !== LABELMAP_BACKGROUND_VALUE + ); + if (!containsForeground) return true; + reject('empty extent references a mask with foreground voxels'); + return false; + } + + if (!sameDimensions(extent, labelmap.getDimensions())) { + reject('extent does not match the loaded mask dimensions'); + return false; + } + if (!extentContains(fullExtent(parentImage.getDimensions()), extent)) { + reject('extent leaves the parent image'); + return false; + } + return true; +} + +/** + * Places each loaded mask on its parent's grid at the bounds its binding + * claims, refusing bounds the labelmap or the image does not support. Nothing + * is shared: a mask that fails validation leaves every other mask alone, + * because each one was read into a buffer of its own. + */ +export function prepareRestoreBindings(input: RestoreBindingInput) { + const { manifest, dataIDMap, loaded, getParentImage } = input; + const acceptedBindings = new WeakMap(); + const skipped: SkippedRestoreItem[] = []; + + const place = (wireMask: WireMask, parentImage: vtkImageData | undefined) => { + const wireBinding = wireMask.representations.labelmap; + const available = loaded.get(wireMask); + if (!wireBinding || !available) return; + + const { name, labelmap, source } = available; + const reject = (reason: string) => skipped.push({ name, reason }); + + if (!parentImage) { + reject('parent image data is unavailable'); + return; + } + + const extent = [...wireBinding.extent] as Extent3D; + if (!validExtent(extent, labelmap, parentImage, reject)) return; + + placeMask(labelmap, parentImage, extent); + if (isEmptyExtent(extent)) setMaskScalars(labelmap, new Uint8Array(0)); + acceptedBindings.set(wireMask, { + image: markRaw(labelmap), + extent, + name, + ...(source ? { source } : {}), + }); + }; + + (manifest.segmentations ?? []).forEach((wire) => { + const parentImageId = dataIDMap[wire.parentImage]; + if (parentImageId === undefined) return; + const parentImage = getParentImage(parentImageId); + orderedWireMasks(wire).forEach((wireMask) => place(wireMask, parentImage)); + }); + + return { acceptedBindings, skipped }; +} + +/** The wire's masks in the order it records, skipping ids it does not name. */ +export function orderedWireMasks(wire: { + masks: T[]; + order: string[]; +}) { + const byId = new Map(wire.masks.map((mask) => [mask.id, mask])); + return wire.order.flatMap((maskId) => { + const mask = byId.get(maskId); + return mask ? [mask] : []; + }); +} diff --git a/src/segmentation/io/stateFile.ts b/src/segmentation/io/stateFile.ts new file mode 100644 index 000000000..3c4a3d44a --- /dev/null +++ b/src/segmentation/io/stateFile.ts @@ -0,0 +1,576 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import type { Ref, ComputedRef } from 'vue'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import vtkLabelMap from '@/src/vtk/LabelMap'; +import { allocateMask } from '@/src/segmentation/masks/storage'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { + createLoadedImageReader, + orderedWireMasks, + prepareRestoreBindings, + type LoadedLabelmap, + type WireMask, +} from '@/src/segmentation/io/restore'; +import { readImage, writeSegmentation } from '@/src/io/readWriteImage'; +import { + planLabelmapImports, + type LabelmapImport, + type LabelmapRestoreSource, +} from '@/src/io/import/labelmapImports'; +import type { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { makeMaskArchivePath } from '@/src/io/state-file/maskArchivePath'; +import type { FileEntry } from '@/src/io/types'; +import type { Maybe, ProcessingResultSource } from '@/src/types'; +import { toLabelmapSegment } from '@/src/segmentation/segment'; +import { cleanUndefined } from '@/src/utils'; +import { normalize } from '@/src/utils/path'; +import { splitLabelmap, toLabelMap } from '@/src/segmentation/io/import'; +import { ensureSameSpace } from '@/src/io/resample/resample'; +import { useDatasetStore } from '@/src/store/datasets'; +import { + listMasks, + maskScalars, + type LabelmapBinding, + type LabelmapSegment, + type SegmentMask, + type Segmentation, +} from '@/src/segmentation/model'; +import { type Extent3D } from '@/src/segmentation/geometry'; + +import type { useImageCacheStore } from '@/src/store/image-cache'; +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import type { DataSelection } from '@/src/utils/dataSelection'; + +/** + * The labelmap codec the state file writes through. Injected because itk-wasm + * and the vti worker have no node counterpart. + */ +export type LabelmapIO = { + write: ( + format: string, + labelmap: vtkLabelMap, + segments: LabelmapSegment[] + ) => Promise; + read: ( + file: File + ) => Promise<{ image: vtkImageData; headerMetadata?: Map }>; +}; + +// ZIP entries are relative; extraction may prefix a root member with a slash. +const archivePathKey = (path: string) => normalize(path).replace(/^\/+/, ''); + +const defaultLabelmapIO: LabelmapIO = { + write: writeSegmentation, + read: readImage, +}; + +/** + * Each mask is its own codec call and each codec call is its own worker, so a + * scene with many masks would start one worker per mask and hold every parsed + * mask at once. Save and restore run this many at a time instead. + */ +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; +} + +/** What the wire needs from the store that owns the records. */ +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; + } + ) => SegmentMask[]; +}; + +export type DeserializeOptions = { + manifest: Manifest; + stateFiles: FileEntry[]; + dataIDMap: Record; + /** Ids the registry minted for the incoming segments, keyed by wire id. */ + segmentIdMap?: Record; + /** + * Per-import restore source, resolved by the restore setup (see + * resolveLabelmapSources in labelmapImports.ts, the single owner of + * the synthesized-leaf and ownership policy). Mapped through dataIDMap here. + */ + labelmapSources?: Record; + io?: LabelmapIO; +}; + +/** + * The state-file half of the segmentation store: what a scene writes to an + * archive and what a restore reads back. Split out so the store itself holds + * the records, and given the store's own accessors rather than reaching for + * them, which keeps the invariants in one place. + */ +export function createSegmentationWire(deps: SegmentationWireDeps) { + const { + segmentations, + saveFormat, + imageCacheStore, + segmentRegistry, + labelmapDescriptorByMask, + createMask, + createBindingForImage, + attachMaskBinding, + decodeSegments, + ensureSegmentationForImage, + getSegmentationForImage, + maskFor, + splitLabelmapIntoMasks, + } = deps; + + /** + * A mask that covers nothing holds no voxels, and an image codec has nothing + * to write; the binding's empty extent is what restores it, so one background + * voxel stands in for the bytes. + */ + function writableMask(parentImageId: string, binding: LabelmapBinding) { + if (binding.image.getDimensions().every((size) => size > 0)) + return binding.image; + const parent = imageCacheStore.getVtkImageData(parentImageId); + return parent ? allocateMask(parent, [0, 0, 0, 0, 0, 0]) : binding.image; + } + + async function serialize( + state: StateFile, + io: LabelmapIO = defaultLabelmapIO + ) { + useSegmentationEditsStore().beforeRead(); + const { zip, manifest } = state; + const format = saveFormat.value; + const usedArchivePaths = new Set(); + + // One archive entry per bound mask, named on the binding itself: a + // labelmap holds that segment's voxels and no other's. + const pathOf = new Map(); + const entries = Object.values(segmentations).flatMap((segmentation) => + listMasks(segmentation).flatMap((segment) => { + const binding = segment.representations.labelmap; + if (!binding) return []; + const path = makeMaskArchivePath( + binding.name, + format, + usedArchivePaths + ); + pathOf.set(segment.id, path); + return [ + { + maskId: segment.id, + parentImageId: segmentation.parentImageId, + binding, + path, + }, + ]; + }) + ); + + delete manifest.segmentationArtifacts; + + manifest.segmentations = Object.values(segmentations).map( + (segmentation) => ({ + id: segmentation.id, + name: segmentation.name, + parentImage: segmentation.parentImageId, + fillOpacity: segmentation.fillOpacity, + outlineOpacity: segmentation.outlineOpacity, + outlineThickness: segmentation.outlineThickness, + masks: listMasks(segmentation).map((segment) => { + const binding = segment.representations.labelmap; + return { + id: segment.id, + segmentId: segment.segmentId, + representations: binding + ? { + labelmap: { + extent: [...binding.extent] as Extent3D, + path: pathOf.get(segment.id)!, + name: binding.name, + ...(binding.source ? { source: binding.source } : {}), + }, + } + : {}, + }; + }), + order: [...segmentation.order], + }) + ); + + await mapWithLimit( + entries, + MASK_IO_CONCURRENCY, + async ({ maskId, parentImageId, binding, path }) => { + zip.file( + path, + await io.write(format, writableMask(parentImageId, binding), [ + labelmapDescriptorByMask.value[maskId], + ]) + ); + } + ); + } + + async function deserialize({ + manifest: incoming, + stateFiles, + dataIDMap, + segmentIdMap = {}, + labelmapSources = {}, + io = defaultLabelmapIO, + }: DeserializeOptions) { + const { imports, segmentations: wireSegmentations } = + planLabelmapImports(incoming); + const manifest = { segmentations: wireSegmentations }; + const maskIdMap: Record = {}; + // Which items reached the scene, by wire id. Each lands as one mask + // per segment and so has no single store id of its own. + const restoredImportIds = new Set(); + // Non-silent drops: every labelmap left out of the restore is recorded + // with a concrete reason so the caller can surface it. + const skipped: Array<{ name: string; reason: string }> = []; + + // A path-less item's store id: the restore setup already resolved which + // STATE id carries its bytes; this only maps that id through dataIDMap. + const sourceStoreId = (item: LabelmapImport) => { + if ('path' in item.input) return undefined; + const source = labelmapSources[item.id]; + return source !== undefined ? dataIDMap[source.stateId] : undefined; + }; + + const sourceReads = new Map>(); + function readImport(item: LabelmapImport, storeId: string | undefined) { + const input = item.input; + const key = + 'path' in input + ? `archive:${archivePathKey(input.path)}` + : `dataset:${storeId}`; + let read = sourceReads.get(key); + if (!read) { + read = (async () => { + if ('path' in input) { + const file = stateFiles.find( + (entry) => + archivePathKey(entry.archivePath) === archivePathKey(input.path) + )?.file; + if (!file) throw new Error('Archive member is missing'); + return io.read(file); + } + return { + image: await loadedImage(storeId!), + headerMetadata: imageCacheStore.imageById[storeId!]?.headerMetadata, + }; + })(); + sourceReads.set(key, read); + } + return read; + } + + const loadedImage = createLoadedImageReader( + (id) => imageCacheStore.imageById[id], + (id) => imageCacheStore.getVtkImageData(id) ?? undefined + ); + + // Skip BEFORE awaiting anything an item whose parent image is + // unresolved, or a path-less one whose datasource never materialized; + // `untilLoaded(undefined)` never times out and would hang restore forever. + const attachable = imports.filter((item) => { + if (dataIDMap[item.parentImage] === undefined) { + skipped.push({ + name: item.name, + reason: 'parent image did not load', + }); + return false; + } + if ('path' in item.input) return true; + const hasImport = sourceStoreId(item) !== undefined; + if (!hasImport) { + skipped.push({ + name: item.name, + reason: 'labelmap source unavailable', + }); + } + return hasImport; + }); + + // Every path-less item's temporary imported dataset must be removed + // exactly ONCE, and only AFTER every item that reads it has settled; + // two items sharing a dataSourceId share one temp dataset id. Collected + // from EVERY item, not just the attachable ones: one skipped at the + // parent-image check may still have imported its leaf. + const tempStoreIdsToRemove = new Set( + imports + .filter((item) => labelmapSources[item.id]?.temporary === true) + .map(sourceStoreId) + .filter((storeId): storeId is string => storeId !== undefined) + ); + + let loaded; + try { + loaded = await Promise.all( + attachable.map(async (item) => { + const storeId = sourceStoreId(item); + try { + const { image, headerMetadata } = await readImport(item, storeId); + const labelmap = toLabelMap( + await ensureSameSpace( + await loadedImage(dataIDMap[item.parentImage]), + image, + true + ) + ); + // A group that carried no descriptors is enumerated here, through + // the same decode live import uses, while its source image is + // still loaded: the temp item dataset is dropped below. + const decoded = item.decode + ? ((await decodeSegments(storeId, labelmap, { + headerMetadata, + })) as LabelmapSegment[]) + : undefined; + return { item, labelmap, decoded }; + } catch { + // A parse/read failure skips just this item and never rejects the + // whole restore; the survivors still attach. + skipped.push({ + name: item.name, + reason: 'could not read/parse labelmap', + }); + return undefined; + } + }) + ); + } finally { + const datasetStore = useDatasetStore(); + tempStoreIdsToRemove.forEach((storeId) => datasetStore.remove(storeId)); + } + + // A saved mask names an archive entry of its own, read into a buffer of + // its own: masks share no storage, whatever a hand-edited manifest says. + const maskLabelmaps = new Map(); + const wireMasks = (manifest.segmentations ?? []).flatMap((wire) => + orderedWireMasks(wire) + ); + await mapWithLimit(wireMasks, MASK_IO_CONCURRENCY, async (wireMask) => { + const binding = wireMask.representations.labelmap; + if (binding?.path === undefined) return; + const name = binding.name ?? ''; + const file = stateFiles.find( + (entry) => + archivePathKey(entry.archivePath) === archivePathKey(binding.path!) + )?.file; + if (!file) { + skipped.push({ name, reason: 'archive member is missing' }); + return; + } + try { + const { image } = await io.read(file); + maskLabelmaps.set(wireMask, { + labelmap: toLabelMap(image), + name, + ...(binding.source ? { source: binding.source } : {}), + }); + } catch { + // One unreadable mask never rejects the restore; the rest attach. + skipped.push({ name, reason: 'could not read/parse labelmap' }); + } + }); + + // Reads, resampling and decoding yield to image deletion. Recheck before + // creating any masks, after every asynchronous placement step has settled. + loaded = loaded.filter((result) => { + if (!result) return false; + if (imageCacheStore.getVtkImageData(dataIDMap[result.item.parentImage])) + return true; + skipped.push({ + name: result.item.name, + reason: 'parent image is unavailable', + }); + return false; + }); + const prepared = prepareRestoreBindings({ + manifest, + dataIDMap, + loaded: maskLabelmaps, + getParentImage: (id) => imageCacheStore.getVtkImageData(id) ?? undefined, + }); + skipped.push(...prepared.skipped); + const { acceptedBindings } = prepared; + + // Why a wire mask cannot become a record, or undefined when it can: a + // mask whose segment did not restore has no identity to show, and a + // second mask for a segment already on this image cannot exist. + const dropReason = (imageId: string, segmentId: Maybe) => { + if (!segmentId) return 'its segment is not in the file'; + if (!segmentRegistry.getSegment(segmentId)) + return 'its segment did not restore'; + if (maskFor(imageId, segmentId)) + return 'the image already has a mask for its segment'; + return undefined; + }; + + (manifest.segmentations ?? []).forEach((wire) => { + const parentImageId = dataIDMap[wire.parentImage]; + if (!imageCacheStore.getVtkImageData(parentImageId)) return; + + // An import into an image that already has masks adds to them: the + // display this scene is set to is the user's, not the incoming file's. + const existing = getSegmentationForImage(parentImageId); + const segmentation = + existing ?? ensureSegmentationForImage(parentImageId); + if (!existing) { + segmentation.name = wire.name; + segmentation.fillOpacity = wire.fillOpacity; + segmentation.outlineOpacity = wire.outlineOpacity; + segmentation.outlineThickness = wire.outlineThickness; + } + + orderedWireMasks(wire).forEach((wireMask) => { + const segmentId = segmentIdMap[wireMask.segmentId]; + const reason = dropReason(parentImageId, segmentId); + if (reason) { + skipped.push({ + name: wireMask.representations.labelmap?.name ?? '', + reason, + }); + return; + } + + const segment = createMask(segmentation.id, segmentId); + + const accepted = acceptedBindings.get(wireMask); + if (accepted) attachMaskBinding(segment.id, accepted); + maskIdMap[wireMask.id] = segment.id; + }); + }); + + // All asynchronous reads have settled. Fill the masks already placed in + // wire order; their identities, selection and tool references stay intact. + loaded.forEach((result) => { + if (!result) return; + const { item, labelmap, decoded } = result; + const parentImageId = dataIDMap[item.parentImage]; + let restored: SegmentMask[]; + if (decoded) { + const descriptors = decoded.map((descriptor) => ({ + ...descriptor, + ...cleanUndefined({ + fillOpacity: item.display.fillOpacity, + outlineOpacity: item.display.outlineOpacity, + visible: + item.display.visible === undefined + ? undefined + : descriptor.visible && item.display.visible, + }), + })); + restored = splitLabelmapIntoMasks( + parentImageId, + labelmap, + descriptors, + { + source: item.source, + name: item.name, + } + ); + const activeIndex = descriptors.findIndex( + (descriptor) => descriptor.value === item.activeValue + ); + const active = restored[activeIndex]; + if (active) segmentRegistry.selectSegment(active.segmentId); + } else { + const segmentation = getSegmentationForImage(parentImageId); + const targets = item.masks.flatMap(({ maskId, value }) => { + const mask = segmentation?.masks[maskIdMap[maskId]]; + if (!mask || !segmentRegistry.getSegment(mask.segmentId)) return []; + return [ + { + mask, + descriptor: toLabelmapSegment( + segmentRegistry.getSegment(mask.segmentId), + value + ), + }, + ]; + }); + const maskByDescriptor = new Map( + targets.map(({ mask, descriptor }) => [descriptor, mask]) + ); + splitLabelmap( + labelmap, + targets.map(({ descriptor }) => descriptor), + (descriptor, extent) => { + const mask = maskByDescriptor.get(descriptor)!; + const binding = createBindingForImage( + parentImageId, + extent, + item.source, + item.name + ); + attachMaskBinding(mask.id, binding); + return { + labelValue: SEGMENT_VALUE, + mask: maskScalars(binding.image), + }; + } + ); + restored = targets.map(({ mask }) => mask); + } + if (restored.length) restoredImportIds.add(item.id); + else + skipped.push({ name: item.name, reason: 'labelmap holds no segments' }); + }); + + return { restoredImportIds, maskIdMap, skipped }; + } + + return { serialize, deserialize }; +} diff --git a/src/segmentation/masks/labelValue.ts b/src/segmentation/masks/labelValue.ts new file mode 100644 index 000000000..4c19eb433 --- /dev/null +++ b/src/segmentation/masks/labelValue.ts @@ -0,0 +1,7 @@ +/** + * The value every mask marks its own voxels with. A mask holds one segment and + * nothing else, so the byte says only claimed or not; which segment it belongs + * to is the mask's identity, not its content. Export assigns its own values at + * write time, where one file does have to tell segments apart per voxel. + */ +export const SEGMENT_VALUE = 1; diff --git a/src/segmentation/masks/overlap.ts b/src/segmentation/masks/overlap.ts new file mode 100644 index 000000000..eb73aacdf --- /dev/null +++ b/src/segmentation/masks/overlap.ts @@ -0,0 +1,297 @@ +import type vtkLabelMap from '@/src/vtk/LabelMap'; +import { + LABELMAP_BACKGROUND_VALUE, + maskScalars, +} from '@/src/segmentation/model'; +import { + clipExtent, + emptyExtent, + extentContainsIndex, + extentSize, + extentUnion, + isEmptyExtent, + maskOffset, + type Extent3D, + type MaskBounds, +} from '@/src/segmentation/geometry'; + +// Bounded masks read as one parent-shaped picture: how a mask is written into +// that picture, and which masks can share one without losing a voxel. + +/** A mask's buffer with the bounds its offsets are taken against. */ +export type BoundedScalars = MaskBounds & { + mask: vtkLabelMap; + scalars: Uint8Array; +}; + +/** + * A mask with the strides its extent implies, absent when it holds nothing. The + * extent is copied because the callers read it per voxel and a segment's own + * copy lives in the reactive tree. + */ +export function boundScalars( + mask: vtkLabelMap | undefined, + bounds: Extent3D +): BoundedScalars | undefined { + if (!mask || isEmptyExtent(bounds)) return undefined; + const extent = [...bounds] as Extent3D; + const [mi, mj] = extentSize(extent); + return { mask, scalars: maskScalars(mask), extent, mi, mj }; +} + +/** + * The masks that reach the box the caller is about to walk. Clipping once here + * is what keeps a mask that misses the box out of the per-voxel containment + * test, and lets the caller skip the walk entirely when none is left. + */ +const masksReaching = (masks: BoundedScalars[], within: Extent3D) => + masks.filter((bounded) => !isEmptyExtent(clipExtent(bounded.extent, within))); + +/** + * Whether any of these masks holds the voxel at PARENT indices i, j, k, over + * the box the caller is about to walk. Absent when no mask reaches that box. + */ +export function masksHolding(masks: BoundedScalars[], within: Extent3D) { + const reaching = masksReaching(masks, within); + if (reaching.length === 0) return undefined; + return (i: number, j: number, k: number) => + reaching.some( + (bounded) => + extentContainsIndex(bounded.extent, i, j, k) && + bounded.scalars[maskOffset(bounded, i, j, k)] !== + LABELMAP_BACKGROUND_VALUE + ); +} + +/** + * 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. + */ +export function masksClearing(masks: BoundedScalars[], within: Extent3D) { + const reaching = masksReaching(masks, within); + if (reaching.length === 0) return undefined; + const changed = new Set(); + const claim = (i: number, j: number, k: number) => { + reaching.forEach((bounded) => { + if (!extentContainsIndex(bounded.extent, i, j, k)) return; + const offset = maskOffset(bounded, i, j, k); + if (bounded.scalars[offset] === LABELMAP_BACKGROUND_VALUE) return; + bounded.scalars[offset] = LABELMAP_BACKGROUND_VALUE; + changed.add(bounded.mask); + }); + return true; + }; + return { + claim, + finish: () => { + changed.forEach((mask) => mask.modified()); + changed.clear(); + }, + }; +} + +/** + * A mask's buffer, positioned where one row of the shared box starts in it. + */ +type MaskRow = { scalars: Uint8Array; from: number }; + +const rowAt = ( + bounded: BoundedScalars, + i: number, + j: number, + k: number +): MaskRow => ({ + scalars: bounded.scalars, + from: maskOffset(bounded, i, j, k), +}); + +/** + * Whether both rows hold a voxel at the same step along i. Background is 0, so + * a claimed voxel is a truthy one. + */ +function rowsIntersect(a: MaskRow, b: MaskRow, count: number) { + const { scalars: av, from: ai } = a; + const { scalars: bv, from: bi } = b; + for (let n = 0; n < count; n += 1) { + if (av[ai + n] && bv[bi + n]) return true; + } + return false; +} + +/** + * Whether two masks claim one voxel in common. A mask is bounded to the voxels + * it holds, so boxes that miss cannot share a voxel and neither buffer is read. + * Boxes that meet are swept a row at a time: two segments that touch nowhere + * usually still share a box, so the sweep is the common case, and each row + * costs one offset per mask with a plain step along i from there. + */ +export function masksIntersect(a: BoundedScalars, b: BoundedScalars) { + const shared = clipExtent(a.extent, b.extent); + if (isEmptyExtent(shared)) return false; + + const [ni] = extentSize(shared); + const i = shared[0]; + for (let k = shared[4]; k <= shared[5]; k += 1) { + for (let j = shared[2]; j <= shared[3]; j += 1) { + if (rowsIntersect(rowAt(a, i, j, k), rowAt(b, i, j, k), ni)) return true; + } + } + return false; +} + +/** + * A layer's claimed voxels, one bit each, over a box that takes in every mask + * being grouped. Asking whether one more mask fits is then a single sweep of + * that mask's own extent, rather than a sweep of a shared box per mask already + * in the layer. + */ +type Occupancy = MaskBounds & { bits: Uint8Array }; + +function newOccupancy(extent: Extent3D): Occupancy { + const [mi, mj, mk] = extentSize(extent); + return { + extent, + mi, + mj, + bits: new Uint8Array(Math.ceil((mi * mj * mk) / 8)), + }; +} + +/** + * Walks `bounded`'s extent a row at a time, handing each row the offset it + * starts at in the mask, the offset the same voxel sits at in `into`, and how + * many voxels the row holds. Stops at the first row answering true. `into` + * must take in the mask's extent. + */ +function maskRows( + bounded: BoundedScalars, + into: MaskBounds, + row: (from: number, to: number, count: number) => boolean +) { + const { extent } = bounded; + const [ni, nj, nk] = extentSize(extent); + for (let index = 0; index < nj * nk; index += 1) { + const j = extent[2] + (index % nj); + const k = extent[4] + Math.floor(index / nj); + const from = maskOffset(bounded, extent[0], j, k); + const to = maskOffset(into, extent[0], j, k); + if (row(from, to, ni)) return true; + } + return false; +} + +/** Whether this mask claims a voxel the layer already holds. */ +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; + }); + +/** Adds this mask's voxels to the ones the layer holds. */ +function occupy(occupied: Occupancy, bounded: BoundedScalars) { + maskRows(bounded, occupied, (from, to, count) => { + for (let n = 0; n < count; n += 1) { + const at = to + n; + // Background is 0, so a voxel the mask leaves unclaimed claims nothing. + if (bounded.scalars[from + n]) occupied.bits[at >> 3] |= 1 << (at & 7); + } + return false; + }); +} + +/** The box taking in every one of these masks, empty when there are none. */ +function maskedBounds(masks: Array) { + let bounds: Extent3D | undefined; + masks.forEach((bounded) => { + if (!bounded) return; + bounds = bounds ? extentUnion(bounds, bounded.extent) : bounded.extent; + }); + return bounds; +} + +/** + * Items grouped so no group holds two masks that claim a voxel in common. + * Greedy first fit: an item takes the lowest group it does not intersect, so a + * segmentation with no overlap stays one group, in order. An item with no mask + * claims nothing and joins the first group. + * + * A layer answers from its occupancy once it holds more than one mask, which + * is what keeps the cost with the masks' extent instead of with mask pairs. A + * layer holding one mask is asked directly, so layers that never take a second + * mask - every mask overlapping every other - allocate nothing. + */ +export function groupByLayer( + items: T[], + maskOf: (item: T) => BoundedScalars | undefined +) { + 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(items[index]); + if (mask) accept(layer, mask); + }); + + return layers.map((layer) => layer.items); +} + +/** + * Marks a bounded mask's voxels in a parent-shaped buffer as `labelValue`. The + * mask's own bytes only say claimed or not, so the value is the caller's. + * Masks are written in `order`, so a later one takes a voxel an earlier one + * also claims. + */ +export function writeMaskInto( + values: Uint8Array | Uint16Array, + dimensions: readonly number[], + bounded: BoundedScalars, + labelValue: number +) { + const { extent, scalars } = bounded; + const [dx, dy] = dimensions; + const [ni, nj, nk] = extentSize(extent); + // Rows flat in one loop, as in masksIntersect: a j loop inside a k loop would + // nest deeper than the style allows once the row test is in it. + for (let row = 0; row < nj * nk; row += 1) { + const j = extent[2] + (row % nj); + const k = extent[4] + Math.floor(row / nj); + const to = extent[0] + j * dx + k * dx * dy; + const from = maskOffset(bounded, extent[0], j, k); + for (let n = 0; n < ni; n += 1) { + // Background is 0, so a voxel this mask leaves unclaimed keeps whatever + // the buffer already holds there. + if (scalars[from + n]) values[to + n] = labelValue; + } + } +} diff --git a/src/segmentation/masks/storage.ts b/src/segmentation/masks/storage.ts new file mode 100644 index 000000000..1e7846132 --- /dev/null +++ b/src/segmentation/masks/storage.ts @@ -0,0 +1,101 @@ +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 { maskScalars } from '@/src/segmentation/model'; +import { + clipExtent, + extentSize, + isEmptyExtent, + maskOffset, + type Extent3D, +} from '@/src/segmentation/geometry'; +import vtkLabelMap from '@/src/vtk/LabelMap'; + +export const setMaskScalars = (mask: vtkLabelMap, values: Uint8Array) => + mask + .getPointData() + .setScalars(vtkDataArray.newInstance({ numberOfComponents: 1, values })); + +/** Places a bounded mask on the same index grid as its parent image. */ +export function placeMask( + mask: vtkLabelMap, + parent: vtkImageData, + extent: Extent3D +) { + const dimensions = isEmptyExtent(extent) ? [0, 0, 0] : extentSize(extent); + const origin = isEmptyExtent(extent) + ? Array.from(parent.getOrigin()) + : Array.from( + parent.indexToWorld([extent[0], extent[2], extent[4]] as Vector3) + ); + mask.setOrigin(origin as Vector3); + mask.setDimensions(dimensions as Vector3); + mask.computeTransforms(); + return dimensions; +} + +export function allocateMask(parent: vtkImageData, extent: Extent3D) { + const mask = vtkLabelMap.newInstance( + parent.get('spacing', 'origin', 'direction') + ); + const dimensions = placeMask(mask, parent, extent); + setMaskScalars( + mask, + new Uint8Array(dimensions[0] * dimensions[1] * dimensions[2]) + ); + 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); + 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 }; + if (isEmptyExtent(shared)) return values; + 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, + parent: vtkImageData, + from: Extent3D, + to: Extent3D +) { + const previous = maskScalars(mask); + const values = reframeMaskScalars(previous, from, to); + placeMask(mask, parent, to); + setMaskScalars(mask, values); + mask.modified(); +} diff --git a/src/segmentation/masks/voxelAccess.ts b/src/segmentation/masks/voxelAccess.ts new file mode 100644 index 000000000..aaf84e79d --- /dev/null +++ b/src/segmentation/masks/voxelAccess.ts @@ -0,0 +1,217 @@ +import type { TypedArray } from '@kitware/vtk.js/types'; + +import type { Maybe } from '@/src/types'; +import type { VoxelGesture } from '@/src/segmentation/model'; +import type { useImageCacheStore } from '@/src/store/image-cache'; +import { regrowMask } from '@/src/segmentation/masks/storage'; +import { + boundScalars, + masksClearing, + masksHolding, +} from '@/src/segmentation/masks/overlap'; +import { + listMasks, + maskScalars, + type LabelmapBinding, + type MaskVoxelAccessor, + type SegmentMask, + type Segmentation, + type VoxelStorage, +} from '@/src/segmentation/model'; +import { + clipExtent, + extentContains, + extentUnion, + fullExtent, + isEmptyExtent, + padExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; + +/** What voxel access needs from the store that owns the records. */ +export type VoxelAccessDeps = { + imageCacheStore: ReturnType; + findMask: (maskId: string) => SegmentMask | undefined; + getMask: (maskId: string) => SegmentMask; + segmentationOfMask: (maskId: string) => Segmentation | undefined; + ensureLabelmapBinding: (maskId: string) => LabelmapBinding; + maskLocked: (mask: SegmentMask) => boolean; +}; + +/** A bound segment's buffer, absent when it has none or holds nothing. */ +export const boundedMask = (binding?: LabelmapBinding) => + binding && boundScalars(binding.image, binding.extent); + +/** + * Reading and growing the voxels behind a mask. Split out so the store holds + * the records; every accessor re-resolves its binding rather than capturing a + * buffer, so none of them outlive a mask they were made for. + */ +export function createVoxelAccess(deps: VoxelAccessDeps) { + const { + imageCacheStore, + findMask, + getMask, + segmentationOfMask, + ensureLabelmapBinding, + maskLocked, + } = deps; + + function requireParentImage(maskId: string) { + const segmentation = segmentationOfMask(maskId); + if (!segmentation) throw new Error('No such segment'); + const parent = imageCacheStore.getVtkImageData(segmentation.parentImageId); + if (!parent) throw new Error('No such parent image'); + return parent; + } + + /** + * Grows one mask, in place, to cover `extent` in parent index space, with + * `padding` voxels of room beyond it when it has to grow at all. + */ + function ensureMaskContains(maskId: string, extent: Extent3D, padding = 0) { + if (isEmptyExtent(extent)) return false; + + const binding = getMask(maskId).representations.labelmap; + if (!binding) throw new Error('No storage: call materialize() first'); + const parent = requireParentImage(maskId); + // Refused before anything is touched, so a rejected growth leaves the mask + // exactly as it was. + const parentExtent = fullExtent(parent.getDimensions()); + if (!extentContains(parentExtent, extent)) + throw new Error('Extent leaves the parent image'); + + const current = binding.extent; + if (!isEmptyExtent(current) && extentContains(current, extent)) + return false; + + const requested = clipExtent(padExtent(extent, padding), parentExtent); + const grown = isEmptyExtent(current) + ? requested + : extentUnion(current, requested); + regrowMask(binding.image, parent, current, grown); + binding.extent = grown; + return true; + } + + /** + * 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. + */ + function voxelStorage( + maskId: string, + findBinding: () => Maybe, + onMissing: () => never + ): VoxelStorage { + const findImage = () => findBinding()?.image; + const requireImage = () => findImage() ?? onMissing(); + const requireScalars = () => maskScalars(requireImage()); + + return { + exists: () => !!findImage(), + image: requireImage, + scalars: requireScalars, + snapshot: () => requireScalars().slice(), + apply: (scalars: TypedArray | number[]) => { + const image = requireImage(); + const data = maskScalars(image); + if (scalars.length !== data.length) { + throw new Error('Scalar length does not match storage'); + } + data.set(scalars); + image.modified(); + }, + ensureContains: (extent: Extent3D, padding = 0) => { + requireImage(); + return ensureMaskContains(maskId, extent, padding); + }, + }; + } + + /** + * The accessor every labelmap consumer that holds a segment routes through. + * The binding is re-resolved on every call rather than captured. + */ + function maskVoxels(maskId: string): MaskVoxelAccessor { + // Validates eagerly: an accessor for a nonexistent segment is refused up + // 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, + materialize: () => ensureLabelmapBinding(maskId), + ...voxelStorage(maskId, findBinding, onMissing), + }; + } + + /** + * The accessor for consumers holding an id a segment may already have left: + * the renderer and the paint widget are computeds keyed on one that can + * 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'); + } + ); + + /** + * 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. + */ + 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] : []; + }); + } + + /** + * 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. + * + * `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, + } + ); + } + + return { + maskVoxels, + findMaskVoxels, + voxelClaim, + }; +} diff --git a/src/segmentation/model.ts b/src/segmentation/model.ts new file mode 100644 index 000000000..5cca8fdf0 --- /dev/null +++ b/src/segmentation/model.ts @@ -0,0 +1,132 @@ +import type { Extent3D } from '@/src/segmentation/geometry'; +import type { ProcessingResultSource } from '@/src/types'; +import type { RGBAColor, TypedArray } from '@kitware/vtk.js/types'; + +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 type LabelmapBinding = { + /** + * This mask's voxels, and no other segment's. Held raw: a vtk object must + * not be proxied, so every writer of a binding marks it. + */ + image: vtkLabelMap; + extent: Extent3D; // the mask's own bounds, in parent image index space + /** Reaches the saved archive's entry path, so a round trip keeps it. */ + name: string; + source?: ProcessingResultSource; +}; + +/** + * 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. + */ +export type SegmentMask = { + id: string; + segmentId: string; + representations: { + // absent until voxels are allocated + labelmap?: LabelmapBinding; + }; +}; + +/** The value a mask voxel carries where no segment claims it. */ +export const LABELMAP_BACKGROUND_VALUE = 0; + +/** The name a segment gets when nothing named it: shared by decode and paint. */ +export const makeDefaultSegmentName = (value: number) => `Segment ${value}`; + +/** + * One mask's label descriptor, derived from the segment it delineates. + * Identity lives on `Segment`; this is the value-keyed view the labelmap + * renderer and the .seg.nrrd writer consume. + */ +export type LabelmapSegment = { + value: number; + name: string; + color: RGBAColor; + visible: boolean; + locked?: boolean; + // Absent on descriptors that come off a file rather than off a segment. + fillOpacity?: number; + outlineOpacity?: number; +}; + +/** vtk declares getData() as number[] | TypedArray; mask storage is typed. */ +export const maskScalars = (mask: vtkLabelMap) => + mask.getPointData().getScalars().getData() as Uint8Array; + +export type Segmentation = { + id: string; + name: string; + parentImageId: string; + masks: Record; + order: string[]; + fillOpacity: number; + outlineOpacity: number; + outlineThickness: number; +}; + +/** The display multipliers every segment of a segmentation is scaled by. */ +export type SegmentationDisplayPatch = Partial< + Pick +>; + +/** + * The voxel operations every labelmap consumer routes through. Storage is one + * bounded mask per segment, sized to the region that segment covers. + * + * `ensureContains` may replace the scalar array, dimensions and strides: + * anything that cached those from `image()` or `scalars()` must re-fetch after + * calling it. + */ +export type VoxelStorage = { + /** + * Whether the storage is still reachable. An accessor outlives what it + * points at, so callers holding one across a deletion check this before a + * read or a write; every other method throws while it is false. + */ + exists(): boolean; + /** The live labelmap. */ + image(): vtkLabelMap; + /** Live mask buffer. Writers publish changes through apply() or image().modified(). */ + scalars(): TypedArray; + /** Detached copy of the mask buffer. */ + snapshot(): TypedArray; + /** Bulk copy-in; keeps image() and scalars() identity, marks it modified. */ + apply(scalars: TypedArray | number[]): void; + /** + * Ensures storage covers `extent`, growing to the union of what it has and + * what it was asked for. Returns whether storage was invalidated + * (scalars/dimensions/strides changed). An empty extent is already covered. + * Throws when the extent leaves the parent image, so callers clip. When the + * extent is not already covered, the mask grows by `padding` voxels beyond + * it on every face (clipped to the parent), so nearby requests that follow + * grow nothing. + */ + ensureContains(extent: Extent3D, padding?: number): boolean; +}; + +/** + * Voxel access for one segment. Re-resolves the binding on every call rather + * than capturing it, so a caller that holds an accessor across a segment + * deletion or a growth sees the current state, not a stale one. `exists()` is + * false, and every storage method throws, before `materialize()`. + */ +export type MaskVoxelAccessor = VoxelStorage & { + /** The current binding, or undefined before any voxels are allocated. */ + binding(): LabelmapBinding | undefined; + /** Allocates storage if needed and returns the binding. Idempotent. */ + materialize(): LabelmapBinding; +}; + +/** Segments in display order. `order` is the authority, `segments` the store. */ +export function listMasks(segmentation: Segmentation) { + return segmentation.order.map((id) => segmentation.masks[id]); +} + +/** Aimed writes clear unlocked neighbors; 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..6f60bae73 --- /dev/null +++ b/src/segmentation/rendering/VtkSegmentationSliceRepresentation.vue @@ -0,0 +1,247 @@ + + + diff --git a/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts b/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts new file mode 100644 index 000000000..bfb018c2a --- /dev/null +++ b/src/segmentation/rendering/__tests__/segmentDisplay.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + SEGMENT_ACTOR_OPACITY, + segmentFillAlpha, + segmentOutlineTables, +} from '@/src/segmentation/rendering/display'; +import type { LabelmapSegment } from '@/src/segmentation/model'; + +const makeMask = ( + value: number, + overrides: Partial = {} +): LabelmapSegment => ({ + value, + name: `Segment ${value}`, + color: [255, 0, 0, 255], + visible: true, + ...overrides, +}); + +describe('segmentFillAlpha', () => { + it('is the segment alpha when the fill is fully opaque', () => { + expect(segmentFillAlpha(makeMask(1, { fillOpacity: 1 }))).toBe(1); + }); + + it('scales the segment alpha by the fill opacity', () => { + expect(segmentFillAlpha(makeMask(1, { fillOpacity: 0.5 }))).toBe(0.5); + }); + + it('hides a fill the user set to zero', () => { + expect(segmentFillAlpha(makeMask(1, { fillOpacity: 0 }))).toBe(0); + }); + + it('hides an invisible segment whatever its fill opacity', () => { + expect( + segmentFillAlpha(makeMask(1, { visible: false, fillOpacity: 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\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); + }); +}); + +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]); + }); + + it('hides an outline the user set to zero', () => { + const tables = segmentOutlineTables( + [makeMask(1, { 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 + ); + + 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('drops the thickness of an invisible segment', () => { + const tables = segmentOutlineTables( + [makeMask(1, { visible: false }), makeMask(2)], + 2, + 1 + ); + + expect(tables.thicknesses).toEqual([0, 2]); + }); + + it('has no entries for an artifact with no bound segments', () => { + expect(segmentOutlineTables([], 2, 1)).toEqual({ + thicknesses: [], + opacities: [], + }); + }); +}); + +describe('SEGMENT_ACTOR_OPACITY', () => { + it('leaves the fill to the transfer functions', () => { + // A fully opaque segment reaches the screen at its own alpha, so the actor + // must not scale it down. + expect(SEGMENT_ACTOR_OPACITY).toBeGreaterThan(0.999); + }); + + it('stays out of the opaque render pass', () => { + // vtk.js treats an image slice at opacity 1 as opaque and restacks it + // against the base image and the sibling segment actors. + expect(SEGMENT_ACTOR_OPACITY).toBeLessThan(1); + }); +}); diff --git a/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts b/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts new file mode 100644 index 000000000..efb2930fb --- /dev/null +++ b/src/segmentation/rendering/__tests__/segmentRenderMask.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +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'; + +function scene(extent: Extent3D) { + const parent = vtkImageData.newInstance({ + spacing: [2, 3, 4], + origin: [10, 20, 30], + direction: [0, 1, 0, -1, 0, 0, 0, 0, 1], + }); + parent.setExtent(4, 9, 5, 10, 6, 11); + const source = allocateMask(parent, extent); + maskScalars(source).fill(1); + source.modified(); + return { parent, source }; +} + +describe('render-only segment slices', () => { + it.each([0, 1, 2])('pads only the displayed plane on axis %i', (axis) => { + const extent: Extent3D = [6, 6, 7, 7, 8, 8]; + const { parent, source } = scene(extent); + const original = maskScalars(source); + const origin = [...source.getOrigin()]; + const rendered = segmentRenderMask(source, parent, extent, { + axis: axis, + index: extent[axis * 2], + })!; + const dimensions = [3, 3, 3]; + dimensions[axis] = 1; + expect(rendered.getDimensions()).toEqual(dimensions); + const center: [number, number, number] = [1, 1, 1]; + center[axis] = 0; + expect(Array.from(rendered.indexToWorld(center))).toEqual(origin); + expect([...maskScalars(rendered)]).toEqual([0, 0, 0, 0, 1, 0, 0, 0, 0]); + expect(source.getDimensions()).toEqual([1, 1, 1]); + expect(source.getOrigin()).toEqual(origin); + expect(maskScalars(source)).toBe(original); + expect([...original]).toEqual([1]); + expect( + segmentRenderMask(source, parent, extent, { + axis: axis, + index: extent[axis * 2] + 1, + }) + ).toBeNull(); + expect( + segmentRenderMask(source, parent, [0, -1, 0, -1, 0, -1], { + axis: axis, + index: 0, + }) + ).toBeNull(); + }); + + it.each([0, 1, 2, 3, 4, 5])('clips padding at parent face %i', (face) => { + const extent: Extent3D = [6, 6, 7, 7, 8, 8]; + const bounds = [4, 9, 5, 10, 6, 11]; + const boundaryAxis = Math.floor(face / 2); + const axis = (boundaryAxis + 1) % 3; + extent[boundaryAxis * 2] = bounds[face]; + extent[boundaryAxis * 2 + 1] = bounds[face]; + const { parent, source } = scene(extent); + const rendered = segmentRenderMask(source, parent, extent, { + axis: axis, + index: extent[axis * 2], + })!; + expect(rendered.getDimensions()[boundaryAxis]).toBe(2); + const corner: [number, number, number] = [0, 0, 0]; + if (face % 2) corner[boundaryAxis] = 1; + expect( + parent.worldToIndex(rendered.indexToWorld(corner))[boundaryAxis] + ).toBeCloseTo(bounds[face]); + expect([...maskScalars(rendered)].reduce((a, b) => a + b, 0)).toBe(1); + }); + + it('refreshes after edits, slice changes and growth', () => { + const extent: Extent3D = [6, 6, 7, 7, 8, 9]; + const { parent, source } = scene(extent); + const first = segmentRenderMask(source, parent, extent, { + axis: 2, + index: 8, + })!; + expect( + segmentRenderMask(source, parent, extent, { axis: 2, index: 8 }) + ).toBe(first); + maskScalars(source)[0] = 0; + source.modified(); + expect( + segmentRenderMask(source, parent, extent, { axis: 2, index: 8 }) + ).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, + })!; + expect([...maskScalars(nextSlice)]).toEqual([0, 0, 0, 0, 1, 0, 0, 0, 0]); + expect( + Array.from(parent.worldToIndex(nextSlice.indexToWorld([1, 1, 0]))) + ).toEqual([6, 7, 9]); + const grown: Extent3D = [5, 6, 7, 7, 8, 9]; + regrowMask(source, parent, extent, grown); + maskScalars(source)[0] = 1; + source.modified(); + const next = segmentRenderMask(source, parent, grown, { + axis: 2, + index: 8, + })!; + 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); + }); +}); diff --git a/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts b/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts new file mode 100644 index 000000000..a5f98344c --- /dev/null +++ b/src/segmentation/rendering/__tests__/segmentSliceVisibility.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + SEGMENT_COINCIDENT_OFFSET, + 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. +// +// `SEGMENT_COINCIDENT_OFFSET` is the coincident-topology polygon offset every +// segment draws at, which lifts it off the coplanar base image. It carries no +// per-segment term: the actors are translucent, so the renderer blends the +// overlap rather than stacking it, and a per-segment offset would do nothing. +// --------------------------------------------------------------------------- + +const EXTENT: Extent3D = [1, 2, 0, 3, 2, 5]; + +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('SEGMENT_COINCIDENT_OFFSET', () => { + it('puts a segment in front of the base image', () => { + const [factor, units] = SEGMENT_COINCIDENT_OFFSET; + + expect(factor).toBeLessThan(0); + expect(units).toBeLessThan(0); + }); + + it('is one offset, not a per-segment one', () => { + expect(SEGMENT_COINCIDENT_OFFSET).toHaveLength(2); + expect( + SEGMENT_COINCIDENT_OFFSET.every((value) => Number.isFinite(value)) + ).toBe(true); + }); +}); diff --git a/src/segmentation/rendering/display.ts b/src/segmentation/rendering/display.ts new file mode 100644 index 000000000..4f9b56c44 --- /dev/null +++ b/src/segmentation/rendering/display.ts @@ -0,0 +1,87 @@ +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]; +} + +// The base image draws at no offset, so every segment sits in front of it. +const SEGMENT_OFFSET_FACTOR = -4; + +/** + * Actor opacity for a segment's slice representation. Per-segment and + * per-segmentation opacity live in the transfer functions, so the actor itself + * carries none. It must stay below 1: vtk.js puts an image slice in the opaque + * render pass at an opacity of 1, which restacks it against the base image and + * the sibling segment actors. + */ +export const SEGMENT_ACTOR_OPACITY = 0.9999; + +/** + * 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 const SEGMENT_COINCIDENT_OFFSET: [number, number] = [ + SEGMENT_OFFSET_FACTOR, + SEGMENT_OFFSET_FACTOR, +]; + +/** + * Fill alpha in 0..1 for the slice representation's piecewise function: the + * segment's own alpha scaled by its fill opacity and by the segmentation's, + * the same way the outline tables compose theirs. + */ +export const segmentFillAlpha = ( + segment: LabelmapSegment, + segmentationOpacity = 1 +) => + segment.visible + ? ((segment.color[3] || 0) / 255) * + (segment.fillOpacity ?? 1) * + 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. + */ +export const segmentOutlineTables = ( + segments: LabelmapSegment[], + segmentationThickness: 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) + ), + }; +}; diff --git a/src/segmentation/rendering/projection.ts b/src/segmentation/rendering/projection.ts new file mode 100644 index 000000000..733976751 --- /dev/null +++ b/src/segmentation/rendering/projection.ts @@ -0,0 +1,61 @@ +import { computed } from 'vue'; + +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { + sameLabelmapSegment, + toLabelmapSegment, +} from '@/src/segmentation/segment'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { + listMasks, + type LabelmapSegment, + type Segmentation, +} from '@/src/segmentation/model'; + +/** What the projection needs from the store that owns the records. */ +export type SegmentProjectionDeps = { + segmentations: Record; + segmentRegistry: SegmentRegistry; +}; + +/** One descriptor per bound mask; unchanged appearances retain object identity. */ +export function createSegmentProjection({ + segmentations, + segmentRegistry, +}: SegmentProjectionDeps) { + function project() { + const byMask: Record = {}; + Object.values(segmentations).forEach((segmentation) => { + listMasks(segmentation).forEach((segment) => { + if (!segment.representations.labelmap) return; + byMask[segment.id] = toLabelmapSegment( + segmentRegistry.getSegment(segment.segmentId), + SEGMENT_VALUE + ); + }); + }); + return byMask; + } + + let projected: Record = {}; + + return computed(() => { + const fresh = project(); + const stable = Object.fromEntries( + Object.entries(fresh).map(([maskId, list]) => { + const previous = projected[maskId]; + return [ + maskId, + previous && sameLabelmapSegment(previous, list) ? previous : list, + ]; + }) + ); + // The record's own identity is what a consumer of the whole projection + // watches, so it survives a change that left every mask alone. + const unchanged = + Object.keys(stable).length === Object.keys(projected).length && + Object.entries(stable).every(([id, list]) => projected[id] === list); + if (!unchanged) projected = stable; + return projected; + }); +} diff --git a/src/segmentation/rendering/renderMask.ts b/src/segmentation/rendering/renderMask.ts new file mode 100644 index 000000000..78a2343c4 --- /dev/null +++ b/src/segmentation/rendering/renderMask.ts @@ -0,0 +1,83 @@ +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 { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { maskScalars } from '@/src/segmentation/model'; +import { + clipExtent, + isEmptyExtent, + padExtent, + 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; + } + > +>(); + +/** Add known background within the scan, without inventing data beyond it. */ +export function segmentRenderMask( + source: vtkLabelMap, + parent: vtkImageData, + extent: Extent3D, + { axis, index: slice }: { axis: number; index: number } +) { + if (isEmptyExtent(extent)) 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; + const key = [ + ...extent, + ...padded, + ...parent.getOrigin(), + ...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); + } + if (cached.mtime !== source.getMTime()) { + const values = reframeMaskScalars( + maskScalars(source), + [...extent], + padded, + maskScalars(cached.image) + ); + const scalars = cached.image.getPointData().getScalars(); + scalars.dataChange(); + // Each stored mask is binary. Supply the range to avoid another scan. + scalars.setRange( + { + min: values.includes(0) ? 0 : SEGMENT_VALUE, + max: values.includes(SEGMENT_VALUE) ? SEGMENT_VALUE : 0, + }, + 0 + ); + cached.image.modified(); + cached.mtime = source.getMTime(); + } + return cached.image; +} diff --git a/src/segmentation/segment.ts b/src/segmentation/segment.ts new file mode 100644 index 000000000..d9dd7ad28 --- /dev/null +++ b/src/segmentation/segment.ts @@ -0,0 +1,99 @@ +import type { RGBAColor } from '@kitware/vtk.js/types'; + +import { + STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + TOOL_COLORS, +} from '@/src/config'; +import type { Maybe } from '@/src/types'; +import { cleanUndefined } from '@/src/utils'; +import type { LabelmapSegment } from '@/src/segmentation/model'; +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. + */ +export type Segment = { + id: string; + name: string; + color: RGBAColor; + // State the user sets on the thing itself, so it holds on every image. + visible: boolean; + locked: boolean; + fillOpacity?: number; + outlineOpacity?: number; + strokeWidth?: number; +}; + +export type SegmentInit = Partial>; + +export const DEFAULT_SEGMENT_COLOR = cssColorToRGBA(TOOL_COLORS[0]); + +const APPEARANCE_DEFAULTS = { + name: '', + color: DEFAULT_SEGMENT_COLOR, + visible: true, + locked: false, + fillOpacity: 1, + outlineOpacity: 1, + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, +}; + +/** + * 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. + */ +export const resolveSegmentAppearance = (type: Maybe) => { + // Absent means the app default, so only stated fields override. + const stated: Partial = type ?? {}; + const resolved = { + ...APPEARANCE_DEFAULTS, + ...cleanUndefined({ + name: stated.name, + color: stated.color, + visible: stated.visible, + locked: stated.locked, + fillOpacity: stated.fillOpacity, + outlineOpacity: stated.outlineOpacity, + strokeWidth: stated.strokeWidth, + }), + }; + return { ...resolved, cssColor: rgbaToCssColor(resolved.color) }; +}; + +export type SegmentAppearance = ReturnType; + +/** + * The descriptor a record projects onto the label value its mask holds, all of + * it resolved from the segment. The labelmap renderer and the .seg.nrrd writer + * consume it. + */ +export const toLabelmapSegment = ( + type: Maybe, + labelValue: number +): LabelmapSegment => { + const resolved = resolveSegmentAppearance(type); + return { + value: labelValue, + name: resolved.name, + color: [...resolved.color] as RGBAColor, + visible: resolved.visible, + locked: resolved.locked, + fillOpacity: resolved.fillOpacity, + outlineOpacity: resolved.outlineOpacity, + }; +}; + +/** Whether two projections of a segment would draw identically. */ +export const sameLabelmapSegment = (a: LabelmapSegment, b: LabelmapSegment) => + a.value === b.value && + a.name === b.name && + a.visible === b.visible && + a.locked === b.locked && + a.fillOpacity === b.fillOpacity && + a.outlineOpacity === b.outlineOpacity && + a.color.every((channel, index) => channel === b.color[index]); diff --git a/src/segmentation/segmentReferences.ts b/src/segmentation/segmentReferences.ts new file mode 100644 index 000000000..62541be05 --- /dev/null +++ b/src/segmentation/segmentReferences.ts @@ -0,0 +1,40 @@ +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 new file mode 100644 index 000000000..21f301339 --- /dev/null +++ b/src/segmentation/segmentRegistry.ts @@ -0,0 +1,337 @@ +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 { + resolveSegmentAppearance, + type Segment, + type SegmentInit, +} from '@/src/segmentation/segment'; +import { omit } from '@/src/utils'; +import { cleanUndefined } from '@/src/utils'; + +/** A segment as a config file states it: css color, appearance all optional. */ +export type ConfiguredSegment = { + color?: string; + fillOpacity?: number; + outlineOpacity?: number; + strokeWidth?: number; +}; + +export type ConfiguredSegments = Record; + +export type SegmentRegistryOptions = { + hasReferences?: (segmentId: string) => boolean; + removeReferences?: (segmentId: string) => void; +}; + +const fromConfigured = ( + name: string, + configured: ConfiguredSegment +): SegmentInit => + cleanUndefined({ + name, + color: configured.color ? cssColorToRGBA(configured.color) : undefined, + fillOpacity: configured.fillOpacity, + outlineOpacity: configured.outlineOpacity, + strokeWidth: configured.strokeWidth, + }); + +const configuredAppearance = ({ + color, + fillOpacity, + outlineOpacity, + strokeWidth, +}: Segment) => ({ color, fillOpacity, outlineOpacity, strokeWidth }); + +/** + * Identity and shared appearance for a family of segments: one instance backs + * paint, rectangles, polygons and rulers together. Explicit order drives the + * picker, shortcuts, serialization and labelmap stacking. + */ +export const createSegmentRegistry = ({ + hasReferences = () => false, + removeReferences = () => {}, +}: SegmentRegistryOptions = {}) => { + const segmentById = ref>({}) as Ref< + Record + >; + + const segmentOrder = ref([]); + const segmentList = computed(() => + segmentOrder.value.map((id) => segmentById.value[id]) + ); + + /** + * Trimmed name to the ids carrying it. Maintained as segments arrive, are + * renamed and leave, so a name lookup and the uniqueness scans cost a probe + * instead of a walk over every segment: an import mints one segment per + * label, and a thousand-label labelmap is in scope. + */ + const idsByName = new Map(); + + const indexName = (name: string, id: string) => { + const key = name.trim(); + const ids = idsByName.get(key); + if (ids) ids.push(id); + else idsByName.set(key, [id]); + }; + + 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); + }; + + /** Whether a segment already carries this name, ignoring surrounding space. */ + const nameTaken = (name: string) => idsByName.has(name); + + const selectedSegmentId = ref>(); + const selectionRevision = ref(0); + + // A type that is gone is not selected. + const selectedSegment = computed(() => + selectedSegmentId.value + ? segmentById.value[selectedSegmentId.value] + : undefined + ); + + const selectSegment = (id: Maybe) => { + selectedSegmentId.value = id && segmentById.value[id] ? id : undefined; + // Reselecting the same segment can still request that its row be revealed. + if (selectedSegmentId.value) selectionRevision.value += 1; + }; + + const getSegment = (id: Maybe) => + id ? segmentById.value[id] : undefined; + + const appearanceOf = (id: Maybe) => + resolveSegmentAppearance(getSegment(id)); + + // Cached: the renderer asks for one index per mask per re-render, and the + // export sort asks twice per comparison. + const orderIndex = computed( + () => 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) => { + if (name === undefined || name === null) return undefined; + const candidates = idsByName.get(name.trim()) ?? []; + // The index keys on the trimmed name; the answer is still an exact match. + const matches = candidates.filter( + (id) => segmentById.value[id]?.name === name + ); + if (matches.length <= 1) return getSegment(matches[0]); + // Several segments carry the name: the first in registry order answers. + return segmentList.value.find((type) => matches.includes(type.id)); + }; + + // The name index and every lookup ignore surrounding space, so the stem has + // to be trimmed as well: asked for a free name for 'Liver ' while 'Liver' is + // taken, an untrimmed stem answered 'Liver ' and seated a second row nothing + // could tell apart from the first. + const uniqueName = (stem: string) => { + const base = stem.trim(); + if (!nameTaken(base)) return base; + let index = 2; + while (nameTaken(`${base} (${index})`)) index += 1; + return `${base} (${index})`; + }; + + const defaultName = () => { + let index = 1; + while (nameTaken(`Segment ${index}`)) index += 1; + return `Segment ${index}`; + }; + + let nextColorIndex = 0; + const nextColor = () => { + const color = cssColorToRGBA(TOOL_COLORS[nextColorIndex]); + nextColorIndex = (nextColorIndex + 1) % TOOL_COLORS.length; + return color; + }; + + /** Mints a segment without touching the selection. Allocates no voxels. */ + const mintSegment = (init: SegmentInit = {}) => { + const id = useIdStore().nextId(); + const stated = cleanUndefined(init); + // Mutated in place, and the default name is searched for only when the + // caller states none: copying the record and the order per mint made an + // import quadratic in its label count. + const segment = { + name: stated.name ?? defaultName(), + color: nextColor(), + visible: true, + locked: false, + ...stated, + id, + }; + segmentById.value[id] = segment; + segmentOrder.value.push(id); + indexName(segment.name, id); + return id; + }; + + const addSegment = (init: SegmentInit = {}) => { + const id = mintSegment(init); + selectSegment(id); + return id; + }; + + const updateSegment = (id: string, patch: SegmentInit) => { + const type = segmentById.value[id]; + if (!type) return; + const next = { ...type, ...patch, id }; + if (next.name !== type.name) { + unindexName(type.name, id); + indexName(next.name, id); + } + segmentById.value[id] = next; + }; + + // Deleting a referenced segment takes its masks and shapes with it; the + // caller owns the confirmation. + const deleteSegment = (id: string) => { + const type = segmentById.value[id]; + if (!type) return; + removeReferences(id); + segmentOrder.value = segmentOrder.value.filter((key) => key !== id); + unindexName(type.name, id); + segmentById.value = omit(segmentById.value, id); + if (selectedSegmentId.value === id) { + selectSegment(segmentOrder.value[0]); + } + }; + + const moveSegment = (id: string, target: string, after = false) => { + if (id === target || !getSegment(id) || !getSegment(target)) return; + const order = segmentOrder.value.filter((key) => key !== id); + order.splice(order.indexOf(target) + Number(after), 0, id); + segmentOrder.value = order; + }; + + /** + * Where `ensureSelectedSegment` would land, selecting and minting nothing. + * An empty registry has no answer, and what would be minted there is a fresh + * segment carrying the defaults. + */ + const presumedSegmentId = () => + selectedSegment.value?.id ?? segmentList.value[0]?.id; + + /** Reuse the selection or first segment, minting only for an empty registry. */ + const ensureSelectedSegment = () => { + if (selectedSegment.value) return selectedSegment.value.id; + const first = segmentList.value[0]; + if (!first) return addSegment(); + selectSegment(first.id); + return first.id; + }; + + /** + * Exact-name lookup, minting on a miss. Import binds descriptors this way, + * so a file's segment lands on the one already carrying that name and the + * registry's own color wins. + */ + const segmentNamed = (name: string, init: SegmentInit = {}) => { + const existing = findSegmentByName(name); + if (existing) return existing.id; + return mintSegment({ ...init, name }); + }; + + // --- config overlay --- // + + // Keep each key's identity and appearance beneath its config contribution. + // New segments begin with automatic color and default optional appearance; + // a restored segment begins with its session appearance. + const configEntries = new Map< + string, + { id: string; appearance: ReturnType } + >(); + + const replaceConfigSegments = (configured: Maybe) => { + const next = configured ?? {}; + + 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)!) }; + } + updateSegment(entry.id, { + ...entry.appearance, + ...fromConfigured(name, props), + }); + configEntries.set(name, entry); + }); + + [...configEntries.entries()] + .filter(([name]) => !(name in next)) + .forEach(([name, { id }]) => { + configEntries.delete(name); + // Content keeps the last configured appearance as session state. + if (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 })); + + /** + * 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) => { + const idMap: Record = {}; + (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 (id in idMap) return; + idMap[id] = mintSegment(init as SegmentInit); + }); + return idMap; + }; + + return { + segmentById, + segmentList, + selectedSegmentId, + selectionRevision, + selectedSegment, + selectSegment, + getSegment, + appearanceOf, + orderIndexOf, + findSegmentByName, + uniqueName, + mintSegment, + addSegment, + updateSegment, + moveSegment, + deleteSegment, + presumedSegmentId, + ensureSelectedSegment, + segmentNamed, + replaceConfigSegments, + serialize, + adopt, + }; +}; + +export type SegmentRegistry = ReturnType; diff --git a/src/segmentation/segments.ts b/src/segmentation/segments.ts new file mode 100644 index 000000000..4bc24427a --- /dev/null +++ b/src/segmentation/segments.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia'; +import { markRaw } from 'vue'; + +import type { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { createSegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { + removeSegmentReferences, + segmentIsReferenced, +} from '@/src/segmentation/segmentReferences'; + +/** + * Paint, rectangles, polygons and rulers share this registry and selection. + * The same segment can hold masks and shapes on every image. + */ +export const useSegmentStore = defineStore('segments', () => { + const registry = createSegmentRegistry({ + hasReferences: segmentIsReferenced, + removeReferences: removeSegmentReferences, + }); + + function serialize(state: StateFile) { + state.manifest.segments = registry.serialize(); + const selected = registry.selectedSegmentId.value; + if (selected) state.manifest.selectedSegment = selected; + } + + /** Fresh ids for the incoming segments; the map remaps every reference. */ + function deserialize(manifest: Manifest) { + const segmentIdMap = registry.adopt(manifest.segments); + const selected = + manifest.selectedSegment && segmentIdMap[manifest.selectedSegment]; + // An import into a populated scene leaves the user's selection alone. + if (selected && !registry.selectedSegment.value) + registry.selectSegment(selected); + return segmentIdMap; + } + + // Raw: a pinia store is reactive, and a proxy of the registry would unwrap + // its refs out from under every consumer that holds them. + return { + segments: markRaw(registry), + serialize, + deserialize, + }; +}); diff --git a/src/segmentation/store.ts b/src/segmentation/store.ts new file mode 100644 index 000000000..3ffbd2682 --- /dev/null +++ b/src/segmentation/store.ts @@ -0,0 +1,719 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; +import { defineStore } from 'pinia'; +import { markRaw, reactive, ref } from 'vue'; +import type { RGBAColor } from '@kitware/vtk.js/types'; + +import { CATEGORICAL_COLORS } from '@/src/config'; +import { NO_NAME } from '@/src/constants'; +import { createMaskFileNamer } from '@/src/segmentation/io/maskFileNaming'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { allocateMask } from '@/src/segmentation/masks/storage'; +import { createSegmentProjection } from '@/src/segmentation/rendering/projection'; +import { createVoxelAccess } from '@/src/segmentation/masks/voxelAccess'; +import { + createSegmentationWire, + type LabelmapIO, +} from '@/src/segmentation/io/stateFile'; + +export type { LabelmapIO }; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { declareManifestRefs } from '@/src/core/manifestRefs'; +import { + decodeLabelmapSegments, + importLabelmapImage, + splitLabelmap, +} from '@/src/segmentation/io/import'; +import { useIdStore } from '@/src/store/id'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import type { Maybe, ProcessingResultSource } from '@/src/types'; +import { + type DataSelection, + getSelectionStem, +} from '@/src/utils/dataSelection'; +import { + DEFAULT_SEGMENTATION_FILL_OPACITY, + listMasks, + makeDefaultSegmentName, + maskScalars, + type LabelmapBinding, + type LabelmapSegment, + type SegmentMask, + type Segmentation, + type SegmentationDisplayPatch, +} from '@/src/segmentation/model'; +import { + emptyExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { declareSegmentReferences } from '@/src/segmentation/segmentReferences'; +import { useMessageStore } from '@/src/store/messages'; +import { + cleanUndefined, + ensureError, + isRecord, + removeFromArray, +} from '@/src/utils'; +import { cycleColors } from '@/src/utils/color'; +import vtkLabelMap from '@/src/vtk/LabelMap'; + +/** + * The labelmap codec the state file writes through. Injected because itk-wasm + * and the vti worker have no node counterpart. + */ + +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`, + }, + ] + : [] + ), + ]; + }); +}); + +/** + * What a caller says about one source label value. Only `value` identifies the + * bin; everything else overrides what the labelmap's own metadata decoded. + */ +export type SourceDescription = Pick & + Partial>; + +export const useSegmentationStore = defineStore('segmentation', () => { + const edits = useSegmentationEditsStore(); + const imageCacheStore = useImageCacheStore(); + const segmentRegistry = useSegmentStore().segments; + + const segmentations = reactive>({}); + const convertingLabelmaps = reactive(new Set()); + // The conversion running for an image, so a second caller joins it instead + // of splitting the same labelmap twice. + const conversions = new Map< + DataSelection, + ReturnType + >(); + /** + * How many bound masks hold each name, so picking a default name probes this + * rather than walking every mask in the scene. A restore attaches the names + * the file states, which may repeat, so it counts holders instead of only + * remembering the name: releasing one mask must not free a name another + * still holds. + */ + const maskNameHolders = new Map(); + const holdMaskName = (name: string) => + maskNameHolders.set(name, (maskNameHolders.get(name) ?? 0) + 1); + const releaseMaskName = (name: string) => { + const holders = maskNameHolders.get(name) ?? 0; + if (holders > 1) maskNameHolders.set(name, holders - 1); + else maskNameHolders.delete(name); + }; + const maskFileNamer = createMaskFileNamer(() => maskNameHolders); + + /** + * Each segmentation's mask id per segment. One image holds at most one mask + * per segment, so the lookup every create, edit and panel row does is a probe + * instead of a walk over `order`. Reactive: components resolve their mask + * inside computeds, so a mask appearing has to reach them. + */ + const maskIdsBySegment = reactive(new Map>()); + + function getSegmentation(segmentationId: string) { + const segmentation = segmentations[segmentationId]; + if (!segmentation) throw new Error('No such segmentation'); + return segmentation; + } + + // SegmentMask ids are globally unique and one segmentation per image is + // enforced, so a segment addresses itself; the segmentation is looked up. + const segmentationOfMask = (maskId: string) => + Object.values(segmentations).find( + (segmentation) => maskId in segmentation.masks + ); + + const findMask = (maskId: string) => + segmentationOfMask(maskId)?.masks[maskId]; + + function getSegmentationOfMask(maskId: string) { + const segmentation = segmentationOfMask(maskId); + if (!segmentation) throw new Error('No such segment'); + return segmentation; + } + + function getMask(maskId: string) { + const segment = findMask(maskId); + if (!segment) throw new Error('No such segment'); + return segment; + } + + const getSegmentationForImage = (parentImageId: string) => + Object.values(segmentations).find( + (segmentation) => segmentation.parentImageId === parentImageId + ); + + function ensureSegmentationForImage(parentImageId: string) { + const existing = getSegmentationForImage(parentImageId); + if (existing) return existing; + + const id = useIdStore().nextId(); + segmentations[id] = { + id, + name: imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME, + parentImageId, + masks: {}, + order: [], + fillOpacity: DEFAULT_SEGMENTATION_FILL_OPACITY, + outlineOpacity: 1, + outlineThickness: 2, + }; + maskIdsBySegment.set(id, new Map()); + return segmentations[id]; + } + + /** Creates one mask per (image, segment), refusing duplicate or dangling identity. */ + function createMask(segmentationId: string, segmentId: string) { + const segmentation = getSegmentation(segmentationId); + if (!segmentRegistry.getSegment(segmentId)) + throw new Error('No such segment type'); + if (maskFor(segmentation.parentImageId, segmentId)) + throw new Error('Segment already has a mask on this image'); + const id = useIdStore().nextId(); + segmentation.masks[id] = { id, segmentId, representations: {} }; + segmentation.order.push(id); + maskIdsBySegment.get(segmentation.id)?.set(segmentId, id); + return segmentation.masks[id]; + } + + /** + * A fresh binding over voxels on the parent's grid, covering `extent`. `name` + * is the name a manifest carried: it reaches the saved zip's entry path, so a + * restore that generated one instead would rename the file on every round + * trip. Duplicates are fine, serialize resolves the archive path against the + * ones it has already used. + */ + function createBindingForImage( + parentImageId: string, + extent: Extent3D = emptyExtent(), + source?: ProcessingResultSource, + name?: string + ): LabelmapBinding { + const imageData = imageCacheStore.getVtkImageData(parentImageId); + if (!imageData) throw new Error('No such parent image'); + + const baseName = + imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME; + return { + image: markRaw(allocateMask(imageData, extent)), + extent, + name: name ?? maskFileNamer.pick(parentImageId, baseName), + ...(source ? { source } : {}), + }; + } + + /** Attaches prepared storage to a mask without exposing its mutable record to importers. */ + function attachMaskBinding(maskId: string, binding: LabelmapBinding) { + const mask = getMask(maskId); + if (mask.representations.labelmap) + throw new Error('Mask already has storage'); + mask.representations.labelmap = { + ...binding, + image: markRaw(binding.image), + extent: [...binding.extent], + }; + holdMaskName(mask.representations.labelmap.name); + return mask.representations.labelmap; + } + + /** Drops a mask, and the voxels it held with it. */ + 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); + } + + /** A mask is editable when the segment it delineates is unlocked. */ + const maskLocked = (mask: SegmentMask) => + segmentRegistry.appearanceOf(mask.segmentId).locked; + + const isLocked = (maskId: string) => + segmentRegistry.appearanceOf(findMask(maskId)?.segmentId).locked; + + /** + * The segment a file's descriptor binds to: the one already carrying that + * exact name, or a new one minted from the file. The registry's own color wins + * on a match. A name already taken on this image mints a suffixed segment + * instead, since one image holds at most one mask per segment. + */ + function bindDescriptorSegment( + parentImageId: string, + descriptor: LabelmapSegment + ) { + const usable = (segmentId: Maybe) => + !!segmentId && + !!segmentRegistry.getSegment(segmentId) && + !maskFor(parentImageId, segmentId); + const existing = segmentRegistry.findSegmentByName(descriptor.name); + if (existing && usable(existing.id)) return existing.id; + // A minted segment takes the file's whole description; a matched one keeps + // what the registry already says, its visibility and lock included. + return segmentRegistry.mintSegment({ + name: segmentRegistry.uniqueName(descriptor.name), + color: [...descriptor.color] as RGBAColor, + visible: descriptor.visible, + locked: descriptor.locked ?? false, + ...cleanUndefined({ + fillOpacity: descriptor.fillOpacity, + outlineOpacity: descriptor.outlineOpacity, + }), + }); + } + + /** + * Mints one mask per descriptor and fills it. The masks + * share one segmentation, so label values are assigned against what is + * already in it and a taken value gets remapped. + */ + function splitLabelmapIntoMasks( + parentImageId: string, + labelmap: vtkLabelMap, + descriptors: LabelmapSegment[], + options: { + source?: ProcessingResultSource; + name?: string; + } = {} + ) { + // Identity is committed before storage: the segmentation, the registry + // segment and the mask record all precede the binding that would be the + // first to notice the parent has gone. Refuse up front, so a conversion + // whose parent was removed while it ran mints nothing at all. + if (!imageCacheStore.getVtkImageData(parentImageId)) + throw new Error('No such parent image'); + edits.beforeEdit(); + const segmentation = ensureSegmentationForImage(parentImageId); + const created: SegmentMask[] = []; + + splitLabelmap(labelmap, descriptors, (descriptor, extent) => { + const segment = createMask( + segmentation.id, + bindDescriptorSegment(parentImageId, descriptor) + ); + + const binding = createBindingForImage( + parentImageId, + extent, + options.source, + options.name + ); + attachMaskBinding(segment.id, binding); + created.push(segment); + + // The copy rewrites the source's value, so the mask holds SEGMENT_VALUE + // whatever the file it came from called this segment. + return { labelValue: SEGMENT_VALUE, mask: maskScalars(binding.image) }; + }); + + return created; + } + + // Deliberately separate from createMask's cursor: a descriptor-less + // labelmap must decode to the same catalog whether it came from a cold + // restore or a live conversion, regardless of how many segments this + // session has otherwise created. + const getNextDecodeColor = cycleColors(CATEGORICAL_COLORS); + + function decodeSegments( + imageId: DataSelection | undefined, + image: vtkLabelMap, + options: { component?: number; headerMetadata?: Map } = {} + ) { + return decodeLabelmapSegments(imageId, image, { + ...options, + // A descriptor-less labelmap reads as the file it arrived in, not as + // 'Segment N'; the cold restore decodes through here too, so the two + // paths keep naming one labelmap alike. + baseName: imageId === undefined ? undefined : getSelectionStem(imageId), + nextColor: getNextDecodeColor, + }); + } + + /** + * Product decision: a segment a result DECLARES but leaves EMPTY appears as + * an empty row, so 'looked and found nothing' is distinguishable from 'never + * looked at all'. The decode only ever sees values the voxels carry, so a + * declared value with no voxels reaches the split only by being appended + * here. This mirrors the seg.nrrd header path, where overlaySegmentMetadata + * appends described values missing from the enumeration 'so nothing + * described is lost', so a declaration reaches the catalog whichever of the + * two it arrived on. The header overlay still runs per component and repeats + * its empties once per one; only this path is component-aware. + * + * 0 is background, never a segment. A declaration any component covered is + * left where that component put it, so appearance stays merged onto the + * decoded descriptor rather than duplicated into an empty twin beside it -- + * `covered` spans every component for exactly that reason, and this runs + * only on the last one. The colour cursor turns only for a declaration that + * named no colour. + */ + function withDeclaredEmpties( + decoded: LabelmapSegment[], + bySourceValue: Map>, + covered: Set + ): LabelmapSegment[] { + const empties: LabelmapSegment[] = []; + bySourceValue.forEach((description, value) => { + if (value === 0 || covered.has(value)) return; + empties.push({ + ...description, + value, + name: description.name ?? makeDefaultSegmentName(value), + color: [...(description.color ?? getNextDecodeColor())] as RGBAColor, + visible: description.visible ?? true, + }); + }); + return [...decoded, ...empties]; + } + + async function convertImageToLabelmap( + imageID: DataSelection, + parentID: DataSelection, + source?: ProcessingResultSource, + descriptions: SourceDescription[] = [] + ) { + // A second conversion of an image already converting would split it again + // and mint a suffixed duplicate of every segment, and the first call's + // cleanup would clear the pending flag while the second still ran. Both + // callers share the one conversion and see it end when it really ends. + const running = conversions.get(imageID); + if (running) return running; + + const bySourceValue = new Map( + descriptions.map((descriptor) => [ + descriptor.value, + cleanUndefined(descriptor), + ]) + ); + // Every source value any component of this image carries voxels for. A + // declaration is empty only when none of them did. + const coveredValues = new Set(); + convertingLabelmaps.add(imageID); + const conversion = importLabelmapImage(imageID, parentID, { + // The empties join the descriptor list here, not at the split: the + // import pairs the masks the split returns with these descriptors by + // position, so the two lists have to be the same one. They wait for the + // last component, once every component has said which values it carries. + decode: async (labelmap, component, componentCount) => { + const decoded = (await decodeSegments(imageID, labelmap, { + component, + })) as LabelmapSegment[]; + decoded.forEach((descriptor) => coveredValues.add(descriptor.value)); + if (component < componentCount - 1) return decoded; + return withDeclaredEmpties(decoded, bySourceValue, coveredValues); + }, + split: (labelmap, descriptors) => { + const created = splitLabelmapIntoMasks( + parentID, + labelmap, + // Identity is chosen by name, so explicit descriptions must precede + // binding to a type shared by other images. + descriptors.map((descriptor) => ({ + ...descriptor, + ...bySourceValue.get(descriptor.value), + })), + { source } + ); + if (created.length && !segmentRegistry.selectedSegment.value) { + segmentRegistry.selectSegment(created[0].segmentId); + } + return created.map((segment) => segment.id); + }, + }); + conversions.set(imageID, conversion); + try { + return await conversion; + } finally { + 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 -- the parent image can be + * removed while the resample runs -- so the rejection needs somewhere to + * land instead of going unhandled. + */ + function startLabelmapConversion( + imageID: DataSelection, + parentID: DataSelection + ) { + return convertImageToLabelmap(imageID, parentID).catch((error) => { + useMessageStore().addError('Failed to convert image to a labelmap', { + error: ensureError(error), + }); + }); + } + + const saveFormat = ref('vti'); + + /** The single voxel-allocation point: no other operation creates storage. */ + function ensureLabelmapBinding(maskId: string) { + const segmentation = getSegmentationOfMask(maskId); + const segment = segmentation.masks[maskId]; + if (segment.representations.labelmap) + return segment.representations.labelmap; + + return attachMaskBinding( + maskId, + createBindingForImage(segmentation.parentImageId) + ); + } + + /** The binding of a segment that may already be gone. */ + const findMaskBinding = (maskId: string) => + findMask(maskId)?.representations.labelmap; + + const { maskVoxels, findMaskVoxels, voxelClaim } = createVoxelAccess({ + imageCacheStore, + findMask, + getMask, + segmentationOfMask, + ensureLabelmapBinding, + maskLocked, + }); + + /** The image's segments in `order`, or none when it has no segmentation. */ + function imageMasks(parentImageId: string) { + const segmentation = getSegmentationForImage(parentImageId); + return segmentation ? listMasks(segmentation) : []; + } + + /** + * The segments of an image a process may edit: unlocked, since a locked one + * is not editable, and holding voxels, since an empty mask has no content to + * process. + */ + function editableMasks(parentImageId: string) { + return imageMasks(parentImageId).flatMap((segment) => { + const binding = segment.representations.labelmap; + if (maskLocked(segment) || !binding || isEmptyExtent(binding.extent)) + return []; + return [{ maskId: segment.id, labelValue: SEGMENT_VALUE }]; + }); + } + + /** + * The masks an image draws, in `order`. One actor each; they are translucent, + * so the renderer blends them rather than stacking them by this order. + */ + function maskLayersForImage(parentImageId: string) { + return imageMasks(parentImageId).flatMap((segment) => { + return segment.representations.labelmap ? [{ maskId: segment.id }] : []; + }); + } + + const updateSegmentationDisplay = ( + segmentationId: string, + patch: SegmentationDisplayPatch + ) => Object.assign(getSegmentation(segmentationId), patch); + + /** The mask holds this segment and nothing else, so its voxels go with it. */ + function deleteMask(maskId: string) { + detachMask(getSegmentationOfMask(maskId), maskId); + } + + function removeSegmentation(segmentationId: string) { + edits.beforeEdit(); + const segmentation = segmentations[segmentationId]; + if (segmentation) + listMasks(segmentation).forEach((mask) => { + const binding = mask.representations.labelmap; + if (binding) releaseMaskName(binding.name); + }); + delete segmentations[segmentationId]; + maskIdsBySegment.delete(segmentationId); + } + + // --- edit targets --- // + + /** This image's mask for a segment, absent when it has none here. */ + const maskFor = (imageId: Maybe, segmentId: Maybe) => { + if (!imageId || !segmentId) return undefined; + const segmentation = getSegmentationForImage(imageId); + if (!segmentation) return undefined; + const maskId = maskIdsBySegment.get(segmentation.id)?.get(segmentId); + return maskId ? segmentation.masks[maskId] : undefined; + }; + + /** The mask for (image, segment). Creates identity only, never voxels. */ + function ensureMask(imageId: string, segmentId: string) { + const existing = maskFor(imageId, segmentId); + if (existing) return existing; + const segmentation = ensureSegmentationForImage(imageId); + return createMask(segmentation.id, segmentId); + } + + /** Whether a segment id is live anywhere, used to tell stale ids from foreign ones. */ + const maskExists = (maskId: string) => !!findMask(maskId); + + // A segment the caller named that no longer exists is a stale reference, not + // a target: the edit falls through to the selected one. + const liveSegmentId = (segmentId: Maybe) => + segmentId && segmentRegistry.getSegment(segmentId) ? segmentId : undefined; + + /** + * The mask an edit would land in, if it already exists. Creates nothing, so + * an operation with nothing to allocate for, erasing above all, can refuse + * before a mask is created. + */ + function findEditTarget(imageId: string, preferredSegmentId?: Maybe) { + const segmentId = + liveSegmentId(preferredSegmentId) ?? + segmentRegistry.selectedSegmentId.value; + return maskFor(imageId, segmentId)?.id; + } + + /** + * Whether the segment an edit would land in is locked. The refusal cannot + * wait for a resolved mask: `resolveEditTarget` returns a mask id, so it has + * to mint the record and its segmentation before anything can be asked about + * the lock, and a refused edit would leave both behind. This answers from the + * segment alone, creating nothing, so every edit path can refuse first. + */ + const editTargetLocked = (preferredSegmentId?: Maybe) => + segmentRegistry.appearanceOf( + liveSegmentId(preferredSegmentId) ?? segmentRegistry.presumedSegmentId() + ).locked; + + /** + * Resolves or creates the mask an edit targets. With nothing selected the + * first edit mints and selects a segment, then takes this image's mask of it. + * Callers refusing a locked segment ask `editTargetLocked` before this. + */ + function resolveEditTarget( + imageId: string, + preferredSegmentId?: Maybe + ) { + edits.beforeEdit(); + const segmentId = + liveSegmentId(preferredSegmentId) ?? + segmentRegistry.ensureSelectedSegment(); + return ensureMask(imageId, segmentId).id; + } + + /** Every image's mask of a segment, for the referenced-segment deletion. */ + const masksOfSegment = (segmentId: string) => + Object.values(segmentations).flatMap((segmentation) => + listMasks(segmentation).filter( + (segment) => segment.segmentId === segmentId + ) + ); + + declareSegmentReferences('labelmaps', { + has: (segmentId) => masksOfSegment(segmentId).length > 0, + remove: (segmentId) => + masksOfSegment(segmentId).forEach((segment) => deleteMask(segment.id)), + }); + + // --- render sync --- // + + const labelmapDescriptorByMask = createSegmentProjection({ + segmentations, + segmentRegistry, + }); + + // --- state file --- // + + const { serialize, deserialize } = createSegmentationWire({ + segmentations, + saveFormat, + imageCacheStore, + segmentRegistry, + labelmapDescriptorByMask, + createMask, + createBindingForImage, + attachMaskBinding, + decodeSegments, + ensureSegmentationForImage, + getSegmentationForImage, + maskFor, + splitLabelmapIntoMasks, + }); + + // --- handle deletions --- // + + onImageDeleted((deleted) => { + deleted.forEach((parentImageId) => { + maskFileNamer.forget(parentImageId); + const id = getSegmentationForImage(parentImageId)?.id; + if (id) removeSegmentation(id); + }); + }); + + return { + segmentations, + convertingLabelmaps, + labelmapDescriptorByMask, + maskFor, + findEditTarget, + resolveEditTarget, + editTargetLocked, + maskExists, + getSegmentationForImage, + ensureSegmentationForImage, + segmentationOfMask, + getMask, + findMaskBinding, + maskVoxels, + findMaskVoxels, + createMask, + ensureLabelmapBinding, + isLocked, + updateSegmentationDisplay, + deleteMask, + removeSegmentation, + splitLabelmapIntoMasks, + decodeSegments, + convertImageToLabelmap, + startLabelmapConversion, + saveFormat, + voxelClaim, + imageMasks, + editableMasks, + maskLayersForImage, + serialize, + deserialize, + }; +}); diff --git a/src/shims-vtk.d.ts b/src/shims-vtk.d.ts index ec7d1a69b..a5caa9159 100644 --- a/src/shims-vtk.d.ts +++ b/src/shims-vtk.d.ts @@ -79,6 +79,8 @@ declare module '@kitware/vtk.js/Widgets/Core/WidgetManager' { } export interface vtkWidgetManager extends vtkObject { + getCursorStyles(): Record; + setCursorStyles(styles: Record): boolean; setCaptureOn(cap: CaptureOn): boolean; getCaptureOn(): CaptureOn; setViewType(type: ViewTypes): boolean; diff --git a/src/store/__tests__/annotationToolImageDelete.spec.ts b/src/store/__tests__/annotationToolImageDelete.spec.ts index b7608a1e4..7f404768c 100644 --- a/src/store/__tests__/annotationToolImageDelete.spec.ts +++ b/src/store/__tests__/annotationToolImageDelete.spec.ts @@ -34,15 +34,7 @@ const makeRuler = ( imageID: string ): RequiredWithPartial< Ruler, - | 'id' - | 'color' - | 'strokeWidth' - | 'label' - | 'labelName' - | 'hidden' - | 'metadata' - | 'frame' - | 'source' + 'id' | 'segmentId' | 'hidden' | 'metadata' | 'frame' | 'source' > => ({ firstPoint: [1, 1, 1], secondPoint: [2, 2, 2], diff --git a/src/store/__tests__/datasetRemoveCascade.spec.ts b/src/store/__tests__/datasetRemoveCascade.spec.ts index 3ec1ef05a..9a6dc7122 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,82 @@ const makeRuler = (imageID: string) => placing: false, }) as never; +/** A segmentation on an image, with one segment's mask allocated. */ +const seatMask = (imageId: string) => { + const segmentations = useSegmentationStore(); + const segmentationId = segmentations.ensureSegmentationForImage(imageId).id; + const maskId = segmentations.createMask(segmentationId, mintSegment()).id; + segmentations.maskVoxels(maskId).materialize(); + return { segmentationId, maskId }; +}; + describe('dataset remove — synchronous reference cascade', () => { beforeEach(() => { setActivePinia(createPinia()); }); - it('clears segment groups whose parent image was removed', () => { + it('clears segment masks whose parent image was removed', () => { seatImage('img-1', 'CT'); - const segmentGroups = useSegmentGroupStore(); - const groupId = segmentGroups.newLabelmapFromImage('img-1'); - expect(groupId).not.toBeNull(); - expect(segmentGroups.orderByParent['img-1']).toContain(groupId); + // The store subscribes to image deletion on setup, so seat it first. + useSegmentationStore(); + const { maskId } = seatMask('img-1'); + expect(boundMasks().map((mask) => mask.id)).toContain(maskId); useDatasetStore().remove('img-1'); - expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); - expect(segmentGroups.metadataByID).not.toHaveProperty(groupId as string); + expect(boundMasks()).toEqual([]); }); - it('clears ALL segment groups when an image has several (no splice-skip)', () => { + it('clears ALL segment masks when an image has several (no splice-skip)', () => { seatImage('img-1', 'CT'); - const segmentGroups = useSegmentGroupStore(); - const groupA = segmentGroups.newLabelmapFromImage('img-1'); - const groupB = segmentGroups.newLabelmapFromImage('img-1'); - const groupC = segmentGroups.newLabelmapFromImage('img-1'); - expect(groupA).not.toBeNull(); - expect(groupB).not.toBeNull(); - expect(groupC).not.toBeNull(); - expect(segmentGroups.orderByParent['img-1']).toEqual([ - groupA, - groupB, - groupC, - ]); + const segmentations = useSegmentationStore(); + const first = seatMask('img-1'); + const rest = ['A', 'B'].map((name) => { + const segment = segmentations.createMask( + first.segmentationId, + mintSegment({ + name, + }) + ); + segmentations.maskVoxels(segment.id).materialize(); + return segment.id; + }); + const maskIds = [first.maskId, ...rest]; + expect( + boundMasks() + .map((mask) => mask.id) + .sort() + ).toEqual([...maskIds].sort()); useDatasetStore().remove('img-1'); - expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); - [groupA, groupB, groupC].forEach((id) => { - expect(segmentGroups.metadataByID).not.toHaveProperty(id as string); - expect(segmentGroups.dataIndex).not.toHaveProperty(id as string); - }); + expect(boundMasks()).toEqual([]); + }); + + it('removes the segmentation and its masks with the parent image', () => { + seatImage('img-1', 'CT'); + const segmentations = useSegmentationStore(); + const { segmentationId, maskId } = seatMask('img-1'); + expect(boundMasks().map((mask) => mask.id)).toContain(maskId); + + useDatasetStore().remove('img-1'); + + expect(segmentations.getSegmentationForImage('img-1')).toBeFalsy(); + expect(segmentations.segmentations).not.toHaveProperty(segmentationId); + expect(boundMasks()).toEqual([]); + }); + + it('leaves another image segmentation intact', () => { + seatImage('img-1', 'CT'); + seatImage('img-2', 'PET'); + const segmentations = useSegmentationStore(); + seatMask('img-1'); + const kept = seatMask('img-2'); + + useDatasetStore().remove('img-1'); + + expect(segmentations.getSegmentationForImage('img-2')).toBeTruthy(); + expect(boundMasks().map((mask) => mask.id)).toEqual([kept.maskId]); }); it('clears annotation tools bound to the removed image', () => { @@ -155,17 +195,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 +245,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 +276,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-dicom-cine.spec.ts b/src/store/__tests__/datasets-dicom-cine.spec.ts index 2d9d424b2..2f1c4fc97 100644 --- a/src/store/__tests__/datasets-dicom-cine.spec.ts +++ b/src/store/__tests__/datasets-dicom-cine.spec.ts @@ -12,6 +12,7 @@ import type { CineParseResult, } from '@/src/core/cine/parseCineDicom'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { instanceTags } from '@/src/store/__tests__/dicomTagFixtures'; import { isCineChunkGroup, useDICOMStore } from '@/src/store/datasets-dicom'; const mocks = vi.hoisted(() => { @@ -87,29 +88,16 @@ vi.mock('@/src/core/streaming/dicomChunkImage', () => ({ })); function metadata(overrides: Record = {}) { - return ( - [ - [Tags.SOPClassUID, SOP_CLASS_ULTRASOUND_MULTIFRAME], - [Tags.NumberOfFrames, '2'], - [Tags.SOPInstanceUID, 'sop-uid'], - [Tags.PatientID, 'patient-1'], - [Tags.PatientName, 'Test Patient'], - [Tags.PatientBirthDate, ''], - [Tags.PatientSex, ''], - [Tags.StudyID, 'study-1'], - [Tags.StudyInstanceUID, 'study-uid'], - [Tags.StudyDate, ''], - [Tags.StudyTime, ''], - [Tags.AccessionNumber, ''], - [Tags.StudyDescription, ''], - [Tags.Modality, 'US'], - [Tags.SeriesInstanceUID, 'series-uid'], - [Tags.SeriesNumber, '7'], - [Tags.SeriesDescription, 'Unsupported native cine'], - [Tags.WindowLevel, ''], - [Tags.WindowWidth, ''], - ] as [string, string][] - ).map(([tag, value]) => [tag, overrides[tag] ?? value]) as [string, string][]; + return instanceTags({ + sopClassUid: SOP_CLASS_ULTRASOUND_MULTIFRAME, + numberOfFrames: '2', + sopInstanceUid: 'sop-uid', + modality: 'US', + seriesDescription: 'Unsupported native cine', + }).map(([tag, value]) => [tag, overrides[tag] ?? value]) as [ + string, + string, + ][]; } function cineHeader(overrides: Partial = {}): CineHeader { diff --git a/src/store/__tests__/datasets-dicom-reimport.spec.ts b/src/store/__tests__/datasets-dicom-reimport.spec.ts index df7fa9ed7..b60cffc94 100644 --- a/src/store/__tests__/datasets-dicom-reimport.spec.ts +++ b/src/store/__tests__/datasets-dicom-reimport.spec.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import type { Chunk } from '@/src/core/streaming/chunk'; -import { Tags } from '@/src/core/dicomTags'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { instanceTags } from '@/src/store/__tests__/dicomTagFixtures'; import { useDICOMStore } from '@/src/store/datasets-dicom'; const mocks = vi.hoisted(() => { @@ -67,27 +67,13 @@ vi.mock('@/src/core/streaming/dicomChunkImage', () => ({ })); function chunk(sopInstanceUid: string) { - const metadata = [ - [Tags.SOPClassUID, '1.2.840.10008.5.1.4.1.1.2'], - [Tags.NumberOfFrames, '1'], - [Tags.SOPInstanceUID, sopInstanceUid], - [Tags.PatientID, 'patient-1'], - [Tags.PatientName, 'Test Patient'], - [Tags.PatientBirthDate, ''], - [Tags.PatientSex, ''], - [Tags.StudyID, 'study-1'], - [Tags.StudyInstanceUID, 'study-uid'], - [Tags.StudyDate, ''], - [Tags.StudyTime, ''], - [Tags.AccessionNumber, ''], - [Tags.StudyDescription, ''], - [Tags.Modality, 'CT'], - [Tags.SeriesInstanceUID, 'series-uid'], - [Tags.SeriesNumber, '7'], - [Tags.SeriesDescription, 'Incremental series'], - [Tags.WindowLevel, ''], - [Tags.WindowWidth, ''], - ] as [string, string][]; + const metadata = instanceTags({ + sopClassUid: '1.2.840.10008.5.1.4.1.1.2', + numberOfFrames: '1', + sopInstanceUid, + modality: 'CT', + seriesDescription: 'Incremental series', + }); return { metadata, metaBlob: new Blob([new Uint8Array([1])]), diff --git a/src/store/__tests__/datasets-layers.spec.ts b/src/store/__tests__/datasets-layers.spec.ts index 1e73059ef..b0ee86382 100644 --- a/src/store/__tests__/datasets-layers.spec.ts +++ b/src/store/__tests__/datasets-layers.spec.ts @@ -14,6 +14,11 @@ vi.mock('@/src/io/resample/resample', () => ({ ensureSameSpace })); import { useLayersStore } from '@/src/store/datasets-layers'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useMessageStore } from '@/src/store/messages'; +import { + ParentToLayers, + type Manifest, + type StateFile, +} from '@/src/io/state-file/schema'; // A unit-spacing cube at `origin`, so its bounds are the numbers the overlap // check reads: an n-wide cube at o spans [o, o + n - 1] on every axis. @@ -104,3 +109,82 @@ describe('useLayersStore.remove', () => { expect(cached('parent::source')).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Regression: the same unguarded dataIDMap lookup de729cfa fixed for +// annotations. A dataset that could not be loaded is absent from the restore +// map, so a saved layer relationship naming it used to reach `addLayer` with a +// missing id. That only fails once the build is already under way, after the +// relationship has been written into `parentToLayers` — keyed by, or pointing +// at, an id no image has. +// --------------------------------------------------------------------------- + +const savedLayers = ( + selectionKey: string, + sourceSelectionKeys: string[] +): Manifest => ({ + version: '1.0.0', + dataSources: [], + parentToLayers: [{ selectionKey, sourceSelectionKeys }], +}); + +/** Serialize the store the way `serialize` does, into a bare manifest. */ +const resave = () => { + const stateFile = { manifest: {} } as unknown as StateFile; + useLayersStore().serialize(stateFile); + return stateFile.manifest.parentToLayers; +}; + +/** Lets every pending layer build settle, successfully or not. */ +const settle = () => + new Promise((resolve) => { + setTimeout(resolve); + }); + +describe('useLayersStore.deserialize with an image that did not load', () => { + it('restores nothing when the layer parent did not load', async () => { + seatImage('source', 0); + const store = useLayersStore(); + + store.deserialize(savedLayers('parent', ['source']), { + source: 'source', + }); + await settle(); + + expect(Object.keys(store.parentToLayers)).toEqual([]); + expect(useMessageStore().messages).toHaveLength(0); + // Before the guard this threw: the failed build left `parentToLayers` with + // a key whose value was `undefined`, and serialize mapped over it. + expect(resave()).toEqual([]); + }); + + it('restores nothing when the layer source did not load', async () => { + seatImage('parent', 0); + const store = useLayersStore(); + + store.deserialize(savedLayers('parent', ['source']), { + parent: 'parent', + }); + await settle(); + + expect(store.getLayers('parent')).toHaveLength(0); + expect(useMessageStore().messages).toHaveLength(0); + // Before the guard the parent's whole relationship was saved with an + // `undefined` source key, which the save-time schema rejects outright. + expect(ParentToLayers.safeParse(resave()).success).toBe(true); + }); + + it('still builds a relationship whose images both came back', () => { + seatOverlappingPair(); + const store = useLayersStore(); + + store.deserialize(savedLayers('saved-parent', ['saved-source']), { + 'saved-parent': 'parent', + 'saved-source': 'source', + }); + + expect(store.getLayers('parent').map(({ id }) => id)).toEqual([ + 'parent::source', + ]); + }); +}); diff --git a/src/store/__tests__/dicomTagFixtures.ts b/src/store/__tests__/dicomTagFixtures.ts new file mode 100644 index 000000000..2e1ecb048 --- /dev/null +++ b/src/store/__tests__/dicomTagFixtures.ts @@ -0,0 +1,43 @@ +import { Tags } from '@/src/core/dicomTags'; + +/** + * The tags one synthetic instance carries, in the order the DICOM store reads + * them. Everything a case does not vary — patient, study, and the series + * identity the store groups on — is fixed here so specs only state what their + * case is about. + */ +export type InstanceTags = { + sopClassUid: string; + numberOfFrames: string; + sopInstanceUid: string; + modality: string; + seriesDescription: string; +}; + +export const instanceTags = ({ + sopClassUid, + numberOfFrames, + sopInstanceUid, + modality, + seriesDescription, +}: InstanceTags): Array<[string, string]> => [ + [Tags.SOPClassUID, sopClassUid], + [Tags.NumberOfFrames, numberOfFrames], + [Tags.SOPInstanceUID, sopInstanceUid], + [Tags.PatientID, 'patient-1'], + [Tags.PatientName, 'Test Patient'], + [Tags.PatientBirthDate, ''], + [Tags.PatientSex, ''], + [Tags.StudyID, 'study-1'], + [Tags.StudyInstanceUID, 'study-uid'], + [Tags.StudyDate, ''], + [Tags.StudyTime, ''], + [Tags.AccessionNumber, ''], + [Tags.StudyDescription, ''], + [Tags.Modality, modality], + [Tags.SeriesInstanceUID, 'series-uid'], + [Tags.SeriesNumber, '7'], + [Tags.SeriesDescription, seriesDescription], + [Tags.WindowLevel, ''], + [Tags.WindowWidth, ''], +]; 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..6526b68da 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,36 @@ 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; -} + ) +); + +/** The plain 4x4x4 parent every restore in this spec hangs off. */ +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 +69,54 @@ describe('segmentGroups.deserialize — legacy manifests without `datasets`', () seatImage('store-seg', 'Tumor'); const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); - const store = useSegmentGroupStore(); - const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( - legacyManifest, - [], + const store = useSegmentationStore(); + const { restoredImportIds: restored, skipped } = await store.deserialize({ + manifest: legacyManifest, + stateFiles: [], // Restore keys every fallback dataset by its stringified source id. - { '1': 'store-ct', '3': 'store-seg' }, - resolveArtifactRestoreSources(legacyManifest) - ); + dataIDMap: { '1': 'store-ct', '3': 'store-seg' }, + segmentIdMap: useSegmentStore().deserialize(legacyManifest), + labelmapSources: resolveLabelmapSources(legacyManifest), + }); expect(skipped).toEqual([]); - expect(idMap['sg-tumor']).toBeDefined(); - expect( - Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') - ).toBe(true); + expect(restored.has('sg-tumor')).toBe(true); // The consumed artifact dataset is removed after conversion. expect(removeSpy).toHaveBeenCalledTimes(1); expect(removeSpy).toHaveBeenCalledWith('store-seg'); + + // The migrated descriptor restored as a segment with its own bounded mask. + const segmentation = store.getSegmentationForImage('store-ct')!; + expect( + listMasks(segmentation).map((segment) => ({ + name: useSegmentStore().segments.appearanceOf(segment.segmentId).name, + bound: !!segment.representations.labelmap, + })) + ).toEqual([{ name: 'Tumor', bound: true }]); + }); + + // The split reuses the segment the manifest named only while that segment + // holds no mask on this image, so the migrated masks must be detached first. + // Splitting before the detach mints a suffixed duplicate instead. + it('reuses the migrated segment rather than minting a second one', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('store-seg', 'Tumor'); + + const store = useSegmentationStore(); + await store.deserialize({ + manifest: legacyManifest, + stateFiles: [], + dataIDMap: { '1': 'store-ct', '3': 'store-seg' }, + segmentIdMap: useSegmentStore().deserialize(legacyManifest), + labelmapSources: resolveLabelmapSources(legacyManifest), + }); + + const names = useSegmentStore().segments.segmentList.value.map( + (segment) => segment.name + ); + expect(names).toEqual(['Tumor']); + expect(listMasks(store.getSegmentationForImage('store-ct')!)).toHaveLength( + 1 + ); }); }); diff --git a/src/store/__tests__/paintProcess.spec.ts b/src/store/__tests__/paintProcess.spec.ts deleted file mode 100644 index b40d0053f..000000000 --- a/src/store/__tests__/paintProcess.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { setActivePinia, createPinia } from 'pinia'; -import { createApp } from 'vue'; -import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { PaintMode } from '@/src/core/tools/paint'; -import { CorePiniaProviderPlugin } from '@/src/core/provider'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { usePaintProcessStore } from '@/src/store/tools/paintProcess'; - -function makeLabelMap(values: Uint8Array) { - const labelMap = vtkLabelMap.newInstance(); - labelMap.setDimensions([values.length, 1, 1]); - labelMap.getPointData().setScalars( - vtkDataArray.newInstance({ - numberOfComponents: 1, - values, - }) - ); - labelMap.computeTransforms(); - return labelMap; -} - -function getScalars(labelMap: vtkLabelMap) { - return Array.from(labelMap.getPointData().getScalars().getData()); -} - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function addTestSegmentGroup(values = new Uint8Array([0, 0])) { - const segmentGroupStore = useSegmentGroupStore(); - const labelMap = makeLabelMap(values); - const groupId = segmentGroupStore.addLabelmap(labelMap, { - name: 'Test group', - parentImage: 'image-1', - segments: { - order: [1], - byValue: { - 1: { - value: 1, - name: 'Segment 1', - color: [255, 0, 0, 255], - visible: true, - locked: false, - }, - }, - }, - }); - - return { groupId, labelMap }; -} - -describe('Paint process store', () => { - beforeEach(() => { - const pinia = createPinia().use(CorePiniaProviderPlugin()); - createApp({}).use(pinia); - setActivePinia(pinia); - }); - - it('opens process controls without changing the paint interaction mode', () => { - const paintStore = usePaintToolStore(); - - paintStore.setMode(PaintMode.Erase); - paintStore.setProcessControlsOpen(true); - - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.Erase); - expect(paintStore.activePaintMode).toBe(PaintMode.Erase); - expect(paintStore.isPaintingModeActive).toBe(true); - }); - - it('uses process interaction mode only while previewing', async () => { - const paintStore = usePaintToolStore(); - const processStore = usePaintProcessStore(); - const { groupId, labelMap } = addTestSegmentGroup(); - - paintStore.setMode(PaintMode.Erase); - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - - expect(paintStore.processControlsOpen).toBe(false); - - await processStore.startProcess( - groupId, - async () => new Uint8Array([2, 2]) - ); - - expect(processStore.processState.step).toBe('previewing'); - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.Process); - expect(paintStore.activePaintMode).toBe(PaintMode.Erase); - expect(paintStore.isPaintingModeActive).toBe(false); - expect(getScalars(labelMap)).toEqual([2, 2]); - - processStore.confirmProcess(); - - expect(processStore.processState.step).toBe('start'); - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.Erase); - expect(paintStore.activePaintMode).toBe(PaintMode.Erase); - expect(paintStore.isPaintingModeActive).toBe(true); - }); - - it('restores the paint interaction mode when preview is canceled', async () => { - const paintStore = usePaintToolStore(); - const processStore = usePaintProcessStore(); - const { groupId, labelMap } = addTestSegmentGroup(); - - paintStore.setMode(PaintMode.CirclePaint); - paintStore.setProcessControlsOpen(true); - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - - await processStore.startProcess( - groupId, - async () => new Uint8Array([3, 3]) - ); - - processStore.cancelProcess(); - - expect(processStore.processState.step).toBe('start'); - expect(paintStore.processControlsOpen).toBe(true); - expect(paintStore.activeMode).toBe(PaintMode.CirclePaint); - expect(paintStore.activePaintMode).toBe(PaintMode.CirclePaint); - expect(paintStore.isPaintingModeActive).toBe(true); - expect(getScalars(labelMap)).toEqual([0, 0]); - }); - - it('ignores stale async results after a newer process starts', async () => { - const paintStore = usePaintToolStore(); - const { groupId, labelMap } = addTestSegmentGroup(); - - paintStore.activeSegmentGroupID = groupId; - paintStore.activeSegment = 1; - paintStore.activeMode = PaintMode.Process; - const processStore = usePaintProcessStore(); - - const first = deferred(); - const second = deferred(); - const firstRun = processStore.startProcess(groupId, () => first.promise); - const secondRun = processStore.startProcess(groupId, () => second.promise); - - first.resolve(new Uint8Array([9, 9])); - await firstRun; - - expect(processStore.processState.step).toBe('computing'); - expect(getScalars(labelMap)).toEqual([0, 0]); - - second.resolve(new Uint8Array([2, 2])); - await secondRun; - - expect(processStore.processState.step).toBe('previewing'); - expect(getScalars(labelMap)).toEqual([2, 2]); - }); -}); diff --git a/src/store/__tests__/rulers.spec.ts b/src/store/__tests__/rulers.spec.ts index f47d88284..fd519bd82 100644 --- a/src/store/__tests__/rulers.spec.ts +++ b/src/store/__tests__/rulers.spec.ts @@ -1,6 +1,15 @@ import { describe, it, beforeEach, expect } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; +import { mintSegment } from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { nextTick } from 'vue'; +import { + STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + TOOL_COLORS, +} from '@/src/config'; +import { cssColorToRGBA, rgbaToCssColor } from '@/src/segmentation/color'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useRulerStore } from '@/src/store/tools/rulers'; import { Ruler } from '@/src/types/ruler'; import { RequiredWithPartial } from '@/src/types'; @@ -8,15 +17,7 @@ import { ToolID } from '@/src/types/annotation-tool'; function createRuler(): RequiredWithPartial< Ruler, - | 'id' - | 'color' - | 'strokeWidth' - | 'label' - | 'labelName' - | 'hidden' - | 'metadata' - | 'frame' - | 'source' + 'id' | 'segmentId' | 'hidden' | 'metadata' | 'frame' | 'source' > { return { firstPoint: [1, 1, 1], @@ -84,3 +85,130 @@ describe('Ruler store', () => { // TODO testing jumpToRuler requires store integration // TODO testing (de)serialize requires store integration }); + +describe('Ruler segment segments', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + // Adding selects, so this is the segment a new ruler picks up. + const seedSegment = (store: ReturnType) => + store.segments.addSegment({ name: 'Tumor' }); + + it('draws out of the shared registry', () => { + const store = useRulerStore(); + const segmentId = useSegmentStore().segments.addSegment({ name: 'Tumor' }); + + expect(store.segments.getSegment(segmentId)?.name).toBe('Tumor'); + expect(store.segments.appearanceOf(segmentId)).toMatchObject({ + name: 'Tumor', + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + }); + }); + + it('adds a ruler carrying the type it was given', () => { + const store = useRulerStore(); + const segmentId = store.segments.addSegment({ name: 'Tumor' }); + + const id = store.addRuler({ ...createRuler(), segmentId }); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + expect(store.appearanceOfTool(id).name).toBe('Tumor'); + }); + + it('defaults a new ruler to the selected type', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + + const id = store.addRuler(createRuler()); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + }); + + it('colors a new segment from the tool palette', () => { + const store = useRulerStore(); + + const id = seedSegment(store); + + expect(store.segments.appearanceOf(id).cssColor).toBe( + rgbaToCssColor(cssColorToRGBA(TOOL_COLORS[0])) + ); + expect(store.segments.selectedSegmentId.value).toBe(id); + }); + + it('shows a rename on the rulers that reference the type', async () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + const id = store.addRuler({ ...createRuler(), segmentId }); + + store.segments.updateSegment(segmentId, { + name: 'Lesion', + color: cssColorToRGBA('blue'), + }); + await nextTick(); + + expect(store.rulerByID[id].segmentId).toBe(segmentId); + expect(store.appearanceOfTool(id)).toMatchObject({ + name: 'Lesion', + cssColor: rgbaToCssColor(cssColorToRGBA('blue')), + }); + }); + + it('takes the rulers of a deleted type with it', async () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + const id = store.addRuler({ ...createRuler(), segmentId }); + + store.segments.deleteSegment(segmentId); + await nextTick(); + + expect(store.rulerByID[id]).toBeUndefined(); + }); + + it('binds a name to the type already carrying it', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + + const id = store.segments.segmentNamed('Tumor'); + + expect(store.segments.segmentList.value).toHaveLength(1); + expect(id).toBe(segmentId); + }); + + it('selects a type, including back to unset', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + + store.segments.selectSegment(undefined); + expect(store.segments.selectedSegmentId.value).toBeUndefined(); + + store.segments.selectSegment(segmentId); + expect(store.segments.selectedSegmentId.value).toBe(segmentId); + }); + + it('names its segments in the shared list, not one of its own', () => { + const store = useRulerStore(); + const segmentId = seedSegment(store); + store.addRuler({ ...createRuler(), segmentId }); + + const manifest = { tools: {} } as any; + store.serialize({ zip: {} as any, manifest }); + const { tools } = store.serializeTools(); + + // The shared store writes the segments; a ruler only references one. + expect(manifest.rulerSegments).toBeUndefined(); + expect(tools[0].segmentId).toBe(segmentId); + }); + + it('shares the registry with the masks painted on an image', () => { + const store = useRulerStore(); + const segmentation = useSegmentationStore().ensureSegmentationForImage('4'); + + const painted = useSegmentationStore().createMask( + segmentation.id, + mintSegment({ name: 'Tumor' }) + ); + + expect(store.segments.getSegment(painted.segmentId)?.name).toBe('Tumor'); + }); +}); diff --git a/src/store/__tests__/views.spec.ts b/src/store/__tests__/views.spec.ts index 41d6057fb..aee365ad2 100644 --- a/src/store/__tests__/views.spec.ts +++ b/src/store/__tests__/views.spec.ts @@ -26,7 +26,7 @@ describe('View store', () => { expect(store.activeView).toBe(store.visibleViews[0].id); }); - it('preserves stored view types when cine data is attached', () => { + it('preserves stored view segments when cine data is attached', () => { const store = useViewStore(); const storedTypes = store.visibleViews.map((view) => view.type); diff --git a/src/store/datasets-layers.ts b/src/store/datasets-layers.ts index 60138fa3d..0aad2eaef 100644 --- a/src/store/datasets-layers.ts +++ b/src/store/datasets-layers.ts @@ -135,8 +135,14 @@ export const useLayersStore = defineStore('layer', () => { parentToLayersSerialized.forEach( ({ selectionKey, sourceSelectionKeys }) => { const parent = remapSelection(selectionKey); + // An image that did not load cannot be a layer parent or a layer + // source. Handing `addLayer` a missing id only fails later, after it + // has already written the relationship into `parentToLayers` under + // that missing id, where the next serialize trips over it. + if (parent === undefined) return; sourceSelectionKeys.forEach((sourceKey) => { const source = remapSelection(sourceKey); + if (source === undefined) return; addLayer(parent, source); }); } 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__/cropRestoreSkipsUnloadedImage.spec.ts b/src/store/tools/__tests__/cropRestoreSkipsUnloadedImage.spec.ts new file mode 100644 index 000000000..2e5ac489e --- /dev/null +++ b/src/store/tools/__tests__/cropRestoreSkipsUnloadedImage.spec.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +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 { collectManifestRefs } from '@/src/core/manifestRefs'; +import { + ManifestSchema, + type Manifest, + type StateFile, +} from '@/src/io/state-file/schema'; +import { useCropStore } from '@/src/store/tools/crop'; + +// --------------------------------------------------------------------------- +// Regression: the same unguarded dataIDMap lookup de729cfa fixed for +// annotations. A dataset that could not be loaded is absent from the restore +// map, so its saved crop planes used to be seated under the key `undefined`. +// Nothing ever deletes that image, so the crop store's onImageDeleted cascade +// cannot drop the entry and every later save carries it: a `tools.crop` key no +// dataset in the file describes, which the save-time reference backstop reports +// as dangling. +// --------------------------------------------------------------------------- + +const LOADED = 'img-loaded'; +const MISSING = 'img-missing'; + +/** A unit-spacing cube wide enough that the planes below need no clamping. */ +const seat = (id: string) => { + const image = vtkImageData.newInstance(); + image.setDimensions(8, 8, 8); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + numberOfComponents: 1, + values: new Uint8Array(8 ** 3), + }) + ); + return useImageCacheStore().addVTKImageData(image, id, { id }); +}; + +const planes = (lower: number, upper: number) => ({ + Sagittal: [lower, upper] as [number, number], + Coronal: [lower, upper] as [number, number], + Axial: [lower, upper] as [number, number], +}); + +/** Crop planes on two images, shaped the way a save writes them. */ +const savedCrop = (): Manifest => ({ + version: '1.0.0', + dataSources: [], + tools: { crop: { [LOADED]: planes(1, 4), [MISSING]: planes(2, 5) } }, +}); + +/** Serialize the store the way `serialize` does, into a bare manifest. */ +const resave = () => { + const stateFile = { manifest: { tools: {} } } as unknown as StateFile; + useCropStore().serialize(stateFile); + return stateFile.manifest; +}; + +describe('restoring crop planes whose image did not load', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('skips the planes of the image that is missing', () => { + seat(LOADED); + useCropStore().deserialize(savedCrop(), { [LOADED]: LOADED }); + + const cropping = useCropStore().croppingByImageID; + expect(Object.keys(cropping)).toEqual([LOADED]); + expect(cropping[LOADED]).toEqual(planes(1, 4)); + }); + + it('keeps the surviving planes saveable and free of dangling references', () => { + seat(LOADED); + useCropStore().deserialize(savedCrop(), { [LOADED]: LOADED }); + + const manifest = resave(); + expect(Object.keys(manifest.tools!.crop!)).toEqual([LOADED]); + expect(ManifestSchema.shape.tools.safeParse(manifest.tools).success).toBe( + true + ); + expect( + collectManifestRefs(manifest as unknown as Record).map( + (ref) => ref.where + ) + ).toEqual([`tools.crop[${LOADED}]`]); + }); + + it('follows the image the planes were remapped onto', () => { + seat('new-id'); + useCropStore().deserialize(savedCrop(), { [LOADED]: 'new-id' }); + + expect(Object.keys(useCropStore().croppingByImageID)).toEqual(['new-id']); + }); + + it('restores nothing when no image came back', () => { + useCropStore().deserialize(savedCrop(), {}); + + expect(useCropStore().croppingByImageID).toEqual({}); + expect(resave().tools!.crop).toEqual({}); + }); +}); diff --git a/src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts b/src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts new file mode 100644 index 000000000..9cdf1af8f --- /dev/null +++ b/src/store/tools/__tests__/restoreSkipsUnloadedImage.spec.ts @@ -0,0 +1,108 @@ +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'; + +// --------------------------------------------------------------------------- +// Regression: a state file whose dataset could not be loaded leaves its id out +// of the restore dataIDMap. Annotations of that image used to be seated with +// imageID undefined, which no view can draw and which makes 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, + }); + +/** Rulers on two images, serialized the way a save writes them. */ +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/crop.ts b/src/store/tools/crop.ts index 2aa6c3fdf..9382f62b4 100644 --- a/src/store/tools/crop.ts +++ b/src/store/tools/crop.ts @@ -195,6 +195,11 @@ export const useCropStore = defineStore('crop', () => { Object.entries(cropping).forEach(([imageID, planes]) => { const newImageID = dataIDMap[imageID]; + // An image that did not load has no extent to clamp against and no view + // to crop. Seating its planes anyway keys them by a missing id, and the + // cascade above can never drop that entry because no such image is ever + // deleted: it is written back out on every later save. + if (newImageID === undefined) return; setCropping(newImageID, planes); }); } diff --git a/src/store/tools/fillBetween.ts b/src/store/tools/fillBetween.ts deleted file mode 100644 index 32690c815..000000000 --- a/src/store/tools/fillBetween.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defineStore } from 'pinia'; -import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; -import { TypedArray } from '@kitware/vtk.js/types'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { morphologicalContourInterpolation } from '@itk-wasm/morphological-contour-interpolation'; - -export const useFillBetweenStore = defineStore('fillBetween', () => { - async function computeAlgorithm( - segImage: vtkLabelMap, - activeSegment: number - ): Promise { - const vtkImage = vtkITKHelper.convertVtkToItkImage(segImage); - const out = await morphologicalContourInterpolation(vtkImage, { - label: activeSegment, - }); - - const vtkOut = vtkITKHelper.convertItkToVtkImage(out.outputImage); - const outputScalars = vtkOut.getPointData().getScalars(); - - return outputScalars.getData() as TypedArray; - } - - return { - computeAlgorithm, - }; -}); diff --git a/src/store/tools/fillHoles.ts b/src/store/tools/fillHoles.ts deleted file mode 100644 index d6c123cee..000000000 --- a/src/store/tools/fillHoles.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import * as Comlink from 'comlink'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { useViewStore } from '@/src/store/views'; -import { useViewSliceStore } from '@/src/store/view-configs/slicing'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; -import { getImageMetadata } from '@/src/composables/useCurrentImage'; -import { getEffectiveView } from '@/src/core/views/effectiveView'; -import { fillHolesWorker } from '@/src/core/tools/paint/fillHoles.worker'; -import { convertSliceIndex } from '@/src/utils/imageSpace'; -import { getLPSDirections } from '@/src/utils/lps'; - -export enum FillHolesSliceScope { - CurrentSlice = 'currentSlice', - WholeVolume = 'wholeVolume', -} - -export enum FillHolesSegmentScope { - AllSegments = 'allSegments', - SelectedSegment = 'selectedSegment', -} - -type WorkerApi = { - fillHolesWorker: typeof fillHolesWorker; -}; - -let workerInstance: Comlink.Remote | null = null; - -async function getWorker() { - if (!workerInstance) { - const worker = new Worker( - new URL('@/src/core/tools/paint/fillHoles.worker.ts', import.meta.url), - { type: 'module' } - ); - workerInstance = Comlink.wrap(worker); - } - return workerInstance; -} - -export const useFillHolesStore = defineStore('fillHoles', () => { - const sliceScope = ref(FillHolesSliceScope.CurrentSlice); - const segmentScope = ref(FillHolesSegmentScope.AllSegments); - - function setSliceScope(value: FillHolesSliceScope) { - sliceScope.value = value; - } - - function setSegmentScope(value: FillHolesSegmentScope) { - segmentScope.value = value; - } - - async function computeAlgorithm( - segImage: vtkLabelMap, - activeSegment: number - ) { - const viewStore = useViewStore(); - const viewSliceStore = useViewSliceStore(); - const paintStore = usePaintToolStore(); - const segmentGroupStore = useSegmentGroupStore(); - - // Fill Holes works on the slice plane of the 2D view the user is on, so a - // 2D view must be active to know which axis (and slice) to operate on. - const effectiveView = getEffectiveView(viewStore.activeView); - if (effectiveView?.kind !== 'volume2D') { - throw new Error( - 'Fill Holes needs an active 2D slice view. Click a 2D view, then try again.' - ); - } - - const groupId = paintStore.activeSegmentGroupID; - if (!groupId) { - throw new Error('No active segment group'); - } - const metadata = segmentGroupStore.metadataByID[groupId]; - - const parentMetadata = getImageMetadata(metadata.parentImage); - const labelMapLpsOrientation = getLPSDirections(segImage.getDirection()); - const axis = labelMapLpsOrientation[effectiveView.axis]; - - const dimensions = segImage.getDimensions() as [number, number, number]; - const data = segImage.getPointData().getScalars().getData(); - - let sliceIndex: number | undefined; - if (sliceScope.value === FillHolesSliceScope.CurrentSlice) { - const sliceConfig = viewSliceStore.getConfig( - effectiveView.viewInfo.id, - metadata.parentImage - ); - const parentAxis = parentMetadata.lpsOrientation[effectiveView.axis]; - const parentSlice = - sliceConfig?.slice ?? - Math.floor(parentMetadata.dimensions[parentAxis] / 2); - sliceIndex = convertSliceIndex( - parentSlice, - parentMetadata.lpsOrientation, - parentMetadata.indexToWorld, - segImage, - effectiveView.axis - ); - } - - const selectedSegment = - segmentScope.value === FillHolesSegmentScope.SelectedSegment; - const label = selectedSegment ? activeSegment : undefined; - // All-segments mode can fill a hole with any bordering label, so guard - // locked segments from being grown. Selected-segment mode only writes the - // active segment, whose lock is already enforced before the process starts. - const lockedLabels = selectedSegment - ? undefined - : Object.values(metadata.segments.byValue) - .filter((segment) => segment.locked) - .map((segment) => segment.value); - - const worker = await getWorker(); - return worker.fillHolesWorker({ - data, - dimensions, - axis, - sliceIndex, - label, - lockedLabels, - }); - } - - return { - sliceScope, - segmentScope, - setSliceScope, - setSegmentScope, - computeAlgorithm, - }; -}); diff --git a/src/store/tools/gaussianSmooth.ts b/src/store/tools/gaussianSmooth.ts deleted file mode 100644 index 0a2f3fe86..000000000 --- a/src/store/tools/gaussianSmooth.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { defineStore } from 'pinia'; -import { ref } from 'vue'; -import * as Comlink from 'comlink'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { gaussianSmoothLabelMapWorker } from '@/src/core/tools/paint/gaussianSmooth.worker'; - -export const DEFAULT_SIGMA = 1.0; -export const MIN_SIGMA = 0.1; -export const MAX_SIGMA = 5.0; - -// Worker management -type WorkerApi = { - gaussianSmoothLabelMapWorker: typeof gaussianSmoothLabelMapWorker; -}; - -let workerInstance: Comlink.Remote | null = null; - -async function getWorker() { - if (!workerInstance) { - // Set up worker with Comlink - const worker = new Worker( - new URL( - '@/src/core/tools/paint/gaussianSmooth.worker.ts', - import.meta.url - ), - { type: 'module' } - ); - workerInstance = Comlink.wrap(worker); - } - return workerInstance; -} - -async function gaussianSmoothLabelMap( - labelMap: vtkLabelMap, - params: { sigma: number; label: number } -) { - const scalars = labelMap.getPointData().getScalars(); - const originalData = scalars.getData(); - const dimensions = labelMap.getDimensions(); - const spacing = labelMap.getSpacing() as [number, number, number]; - - const worker = await getWorker(); - - const workerInput = { - data: originalData, - dimensions, - spacing, - params, - }; - - return worker.gaussianSmoothLabelMapWorker(workerInput); -} - -export const useGaussianSmoothStore = defineStore('gaussianSmooth', () => { - const sigma = ref(DEFAULT_SIGMA); - - function setSigma(value: number) { - sigma.value = Math.max(MIN_SIGMA, Math.min(MAX_SIGMA, value)); - } - - async function computeAlgorithm( - segImage: vtkLabelMap, - activeSegment: number - ) { - const params = { - sigma: sigma.value, - label: activeSegment, - }; - - return gaussianSmoothLabelMap(segImage, params); - } - - return { - sigma, - setSigma, - computeAlgorithm, - }; -}); diff --git a/src/store/tools/index.ts b/src/store/tools/index.ts index 5f0bacdf8..802131322 100644 --- a/src/store/tools/index.ts +++ b/src/store/tools/index.ts @@ -13,6 +13,7 @@ import { plural } from '@/src/utils'; import { AnnotationToolType, IToolStore, Tools } from './types'; import { usePolygonStore } from './polygons'; import { useToolSelectionStore } from './toolSelection'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { useViewStore } from '@/src/store/views'; import { EffectiveView, @@ -31,6 +32,16 @@ export function isToolAllowedFor(tool: Tools, effective: EffectiveView | null) { return true; } +// These tools draw into the selected segment, so picking one up seats a +// segment: the palette shows the color the next stroke will be before it is +// made. Seating allocates no voxels. +const SEGMENT_TOOLS = new Set([ + Tools.Paint, + Tools.Rectangle, + Tools.Ruler, + Tools.Polygon, +]); + const activeEffectiveView = () => getEffectiveView(useViewStore().activeView); function coerceForEffective(tool: Tools, effective: EffectiveView | null) { @@ -131,6 +142,9 @@ export const useToolStore = defineStore('tool', () => { } teardownTool(currentTool.value); currentTool.value = coerced; + if (SEGMENT_TOOLS.has(coerced)) { + useSegmentStore().segments.ensureSelectedSegment(); + } } function activateTemporaryCrosshairs() { @@ -173,18 +187,14 @@ export const useToolStore = defineStore('tool', () => { function deserialize( manifest: Manifest, - segmentGroupIDMap: Record, + segmentIdMap: Record, dataIDMap: Record ) { - usePaintToolStore().deserialize(manifest, segmentGroupIDMap); - Object.values(ToolStoreMap) - // paint store uses segmentGroupIDMap - .filter((useStore) => useStore !== usePaintToolStore) .map((useStore) => useStore?.()) .filter((store): store is IToolStore => !!store) .forEach((store) => { - store.deserialize?.(manifest, dataIDMap); + store.deserialize?.(manifest, dataIDMap, segmentIdMap); }); if (manifest.tools?.current) { diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 9abe2e976..f26ed6789 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -1,8 +1,10 @@ +import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; import type { Vector2, Vector3 } from '@kitware/vtk.js/types'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import type { Manifest, StateFile } from '@/src/io/state-file/schema'; import type { Maybe } from '@/src/types'; import { useImageStatsStore } from '@/src/store/image-stats'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; import { computed, ref, unref, watch } from 'vue'; import { watchImmediate } from '@vueuse/core'; import { vec3 } from 'gl-matrix'; @@ -10,32 +12,26 @@ import { defineStore } from 'pinia'; import { PaintMode } from '@/src/core/tools/paint'; import { computeEffectiveView } from '@/src/core/views/effectiveView'; import { worldPointToIndex } from '@/src/utils/imageSpace'; +import { maskScalars } from '@/src/segmentation/model'; +import { + clipExtent, + fullExtent, + isEmptyExtent, + type Extent3D, +} from '@/src/segmentation/geometry'; import { Tools } from './types'; -import { useSegmentGroupStore } from '../segmentGroups'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useSegmentationStore } from '@/src/segmentation/store'; import useViewSliceStore from '../view-configs/slicing'; import { useViewStore } from '../views'; import { useViewCameraStore } from '../view-configs/camera'; import { useImageCacheStore } from '../image-cache'; -import { declareManifestRefs } from '@/src/core/manifestRefs'; -import { isRecord } from '@/src/utils'; - -// The manifest reference this store's sync orphan-watch keeps clean (see the -// activeSegmentGroupID watch below), declared for the dev-only save backstop. -declareManifestRefs('tools.paint', (manifest) => { - const tools = isRecord(manifest.tools) ? manifest.tools : {}; - const paint = isRecord(tools.paint) ? tools.paint : {}; - return typeof paint.activeSegmentGroupID === 'string' - ? [ - { - kind: 'segmentGroup' as const, - id: paint.activeSegmentGroupID, - where: 'tools.paint.activeSegmentGroupID', - }, - ] - : []; -}); const DEFAULT_BRUSH_SIZE = 4; + +// Growing a mask copies the whole of it, so a stroke that has to grow it asks +// for room beyond its footprint and the samples that follow grow nothing. +const STROKE_GROWTH_PADDING = 16; const DEFAULT_THRESHOLD_RANGE: Vector2 = [ Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, @@ -47,8 +43,6 @@ export const usePaintToolStore = defineStore('paint', () => { const activeMode = ref(PaintMode.CirclePaint); const modeBeforeProcess = ref(PaintMode.CirclePaint); const processControlsOpen = ref(false); - const activeSegmentGroupID = ref>(null); - const activeSegment = ref>(null); const brushSize = ref(DEFAULT_BRUSH_SIZE); const strokePoints = ref([]); const isActive = ref(false); @@ -56,7 +50,6 @@ export const usePaintToolStore = defineStore('paint', () => { const crossPlaneSync = ref(false); const paintPosition = ref([0, 0, 0]); const activePaintViewID = ref>(null); - const lastSegmentByGroup = ref>({}); const { currentImageID, currentImageMetadata } = useCurrentImage('global'); const imageStatsStore = useImageStatsStore(); @@ -68,28 +61,13 @@ export const usePaintToolStore = defineStore('paint', () => { return this.$paint.factory; } - const segmentGroupStore = useSegmentGroupStore(); - - // Delete-base cleanup: removing a dataset cascades away its segment groups. - // `serialize` writes the raw `activeSegmentGroupID`, so null it the instant - // its record leaves the store or the save manifest carries an orphaned id. - // Sync flush keeps this within the same `datasetStore.remove` call — the same - // remove-cascade contract as onImageDeleted, but keyed on segmentGroupID (not - // imageID), so it watches the record set instead of using that composable. - watch( - () => - activeSegmentGroupID.value != null && - !(activeSegmentGroupID.value in segmentGroupStore.metadataByID), - (orphaned) => { - if (orphaned) activeSegmentGroupID.value = null; - }, - { flush: 'sync' } - ); + const segmentationStore = useSegmentationStore(); const isPaintingModeActive = computed( () => activeMode.value === PaintMode.CirclePaint || - activeMode.value === PaintMode.Erase + activeMode.value === PaintMode.Erase || + activeMode.value === PaintMode.Eyedropper ); const activePaintMode = computed(() => isPaintingModeActive.value ? activeMode.value : modeBeforeProcess.value @@ -141,71 +119,31 @@ 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; + // Asked of the segment before the target is resolved, since resolving mints + // the mask record and its segmentation: a refused stroke leaves neither. + if (segmentationStore.editTargetLocked()) return undefined; + const maskId = allocate + ? segmentationStore.resolveEditTarget(imageID) + : segmentationStore.findEditTarget(imageID); + if (!maskId) return undefined; + + const binding = allocate + ? segmentationStore.ensureLabelmapBinding(maskId) + : segmentationStore.findMaskBinding(maskId); + if (!binding) return undefined; + + return { + maskId, + labelValue: SEGMENT_VALUE, + voxels: segmentationStore.maskVoxels(maskId), + }; } /** @@ -218,69 +156,131 @@ export const usePaintToolStore = defineStore('paint', () => { this.$paint.setBrushSize(size); } - function doPaintStroke(this: _This, axisIndex: 0 | 1 | 2, imageID: string) { - const segmentGroupID = getValidSegmentGroupID(imageID); - if (!segmentGroupID) return; + function selectSegmentAt(worldPoint: vec3, imageID: string) { + const registry = useSegmentStore().segments; + // The eyedropper takes the first registry entry covering the point, + // including locked segments. + const segments = registry.segmentList.value; + const hit = segments.find((segment) => { + if (!registry.appearanceOf(segment.id).visible) return false; + const binding = segmentationStore.maskFor(imageID, segment.id) + ?.representations.labelmap; + if (!binding || isEmptyExtent(binding.extent)) return false; + const point = [...worldPointToIndex(binding.image, worldPoint)].map( + Math.round + ); + const dims = binding.image.getDimensions(); + if (point.some((value, axis) => value < 0 || value >= dims[axis])) + return false; + const [i, j, k] = point; + return ( + maskScalars(binding.image)[i + dims[0] * (j + dims[1] * k)] === + SEGMENT_VALUE + ); + }); + if (hit) registry.selectSegment(hit.id); + } - const labelmap = segmentGroupStore.dataIndex[segmentGroupID]; - if (!labelmap) return; + function doPaintStroke(this: _This, axisIndex: 0 | 1 | 2, imageID: string) { + useSegmentationEditsStore().beforeEdit(); + const erasing = activeMode.value === PaintMode.Erase; + const target = resolveStrokeTarget(imageID, !erasing); + if (!target) return; + + const { voxels, labelValue, maskId } = target; + this.$paint.setBrushValue(labelValue); + + const parentImage = useImageCacheStore().getVtkImageData(imageID); + if (!parentImage) return; + const underlyingImagePixels = parentImage + .getPointData() + .getScalars() + .getData(); - // Prevent painting if active segment is locked or doesn't exist - if (activeSegment.value) { - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (!metadata) return; + const lastIndex = strokePoints.value.length - 1; + if (lastIndex < 0) return; + + // The stroke is stated in PARENT index space: a bounded mask's own origin + // moves as it grows, so its indices are not a fixed frame to state it in. + const lastIndexPoint = worldPointToIndex( + parentImage, + strokePoints.value[lastIndex] + ); + const prevIndexPoint = + lastIndex >= 1 + ? worldPointToIndex(parentImage, strokePoints.value[lastIndex - 1]) + : undefined; - const segment = metadata.segments.byValue[activeSegment.value]; - if (!segment || segment.locked) { - return; - } + const strokeExtent = clipExtent( + this.$paint.strokeBounds(axisIndex, lastIndexPoint, prevIndexPoint), + fullExtent(parentImage.getDimensions()) + ); + // Growth happens first, and nothing grows once the buffers below are read. + if (!erasing) { + voxels.ensureContains(strokeExtent, STROKE_GROWTH_PADDING); } - const imageData = useImageCacheStore().getVtkImageData(imageID); - const underlyingImagePixels = imageData - ?.getPointData() - .getScalars() - .getData(); + // Copied out of the reactive tree: the two closures below read it for + // every voxel the brush touches. + const extent = [...voxels.binding()!.extent] as Extent3D; + if (isEmptyExtent(extent)) return; + + // Resolved once per stroke: the claim below is made for every voxel the + // brush touches. A stroke is aimed at a place, so it takes the voxel. + const claimVoxel = segmentationStore.voxelClaim( + maskId, + 'aimed', + strokeExtent + ); + const parentDimensions = parentImage.getDimensions(); + const 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]; + const toParent = (point: number[]) => [ + point[0] + extent[0], + point[1] + extent[2], + point[2] + extent[4], + ]; + + const shouldPaint = (offset: number, point: number[]) => { + const [i, j, k] = toParent(point); + // Erase clears the active segment only. + if (erasing && maskData[offset] !== labelValue) return false; + + const pixValue = + underlyingImagePixels[ + i + + j * parentDimensions[0] + + k * parentDimensions[0] * parentDimensions[1] + ]; return minThreshold <= pixValue && pixValue <= maxThreshold; }; - const lastIndex = strokePoints.value.length - 1; - if (lastIndex >= 0) { - const lastWorldPoint = strokePoints.value[lastIndex]; - const prevWorldPoint = - lastIndex >= 1 ? strokePoints.value[lastIndex - 1] : undefined; - - const lastIndexPoint = worldPointToIndex(labelmap, lastWorldPoint); - const prevIndexPoint = prevWorldPoint - ? worldPointToIndex(labelmap, prevWorldPoint) - : undefined; + const toMask = (point: vec3) => + vec3.fromValues( + point[0] - extent[0], + point[1] - extent[2], + point[2] - extent[4] + ); + try { this.$paint.paintLabelmap( - labelmap, + voxels.image(), axisIndex, - lastIndexPoint, - prevIndexPoint, - shouldPaint + toMask(lastIndexPoint), + { + endPoint: prevIndexPoint ? toMask(prevIndexPoint) : undefined, + shouldPaint, + onPainted: erasing + ? undefined + : (point: number[]) => { + const [i, j, k] = toParent(point); + claimVoxel?.claim(i, j, k); + }, + } ); + } finally { + claimVoxel?.finish(); } } @@ -294,42 +294,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 +347,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 +420,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 +434,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 +441,6 @@ export const usePaintToolStore = defineStore('paint', () => { activeMode, activePaintMode, processControlsOpen, - activeSegmentGroupID, - activeSegment, brushSize, strokePoints, isActive, @@ -499,13 +457,12 @@ export const usePaintToolStore = defineStore('paint', () => { setProcessControlsOpen, enterProcessMode, restoreModeAfterProcess, - setActiveSegmentGroup, - setActiveSegment, setBrushSize, setSliceAxis, setThresholdRange, setCrossPlaneSync, updatePaintPosition, + selectSegmentAt, startStroke, placeStrokePoint, endStroke, diff --git a/src/store/tools/paintProcess.ts b/src/store/tools/paintProcess.ts deleted file mode 100644 index 8220d4615..000000000 --- a/src/store/tools/paintProcess.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { defineStore, storeToRefs } from 'pinia'; -import { ref, computed, watch } from 'vue'; -import { TypedArray } from '@kitware/vtk.js/types'; -import vtkLabelMap from '@/src/vtk/LabelMap'; -import { usePaintToolStore } from '@/src/store/tools/paint'; -import { PaintMode } from '@/src/core/tools/paint'; -import { useMessageStore } from '@/src/store/messages'; -import { useCurrentImage } from '@/src/composables/useCurrentImage'; -import { useSegmentGroupStore } from '../segmentGroups'; - -export enum ProcessType { - FillHoles = 'fillHoles', - FillBetween = 'fillBetween', - GaussianSmooth = 'gaussianSmooth', -} - -type StartState = { - step: 'start'; -}; - -type ComputingState = { - step: 'computing'; - activeParentImageID: string | null; - activeSegmentGroupID: string; - processType: ProcessType; -}; - -type PreviewingState = { - step: 'previewing'; - activeParentImageID: string | null; - activeSegmentGroupID: string; - processType: ProcessType; - segImage: vtkLabelMap; - originalScalars: TypedArray | number[]; - processedScalars: TypedArray | number[]; - showingOriginal: boolean; -}; - -type ProcessState = StartState | ComputingState | PreviewingState; - -export type ProcessAlgorithm = ( - segImage: vtkLabelMap, - activeSegment: number -) => Promise; - -export const usePaintProcessStore = defineStore('paintProcess', () => { - const processState = ref({ step: 'start' }); - const activeProcessType = ref(ProcessType.FillHoles); - let activeProcessRunId = 0; - - const processStep = computed(() => processState.value.step); - - const showingOriginal = computed(() => { - const state = processState.value; - return state.step === 'previewing' ? state.showingOriginal : false; - }); - - function resetState() { - processState.value = { step: 'start' }; - } - - function confirmProcess() { - const state = processState.value; - // Apply commits the processed result. When the user is viewing the - // original, the image currently holds originalScalars, so restore the - // processed scalars before finishing or the result is silently discarded. - if (state.step === 'previewing' && state.showingOriginal) { - state.segImage - .getPointData() - .getScalars() - .setData(state.processedScalars); - state.segImage.modified(); - } - resetState(); - paintStore.restoreModeAfterProcess(); - } - - const segmentGroupStore = useSegmentGroupStore(); - const paintStore = usePaintToolStore(); - const { activeSegmentGroupID } = storeToRefs(paintStore); - const messageStore = useMessageStore(); - const { currentImageID } = useCurrentImage('global'); - - function rollbackPreview( - image: vtkLabelMap, - originalScalars: TypedArray | number[] - ): void { - image.getPointData().getScalars().setData(originalScalars); - image.modified(); - } - - function cancelProcess() { - const state = processState.value; - - if (state.step === 'previewing') { - rollbackPreview(state.segImage, state.originalScalars); - } - resetState(); - paintStore.restoreModeAfterProcess(); - } - - function setActiveProcessType(processType: ProcessType) { - // Cancel any active process before switching - cancelProcess(); - activeProcessType.value = processType; - } - - async function startProcess( - groupId: string, - algorithm: ProcessAlgorithm, - options?: { requiresActiveSegment?: boolean } - ) { - const activeSegment = paintStore.activeSegment; - // Most processes operate on the active segment; all-segments processes opt - // out so they are not blocked by (or limited to) a single active segment. - const requiresActiveSegment = options?.requiresActiveSegment ?? true; - - if (requiresActiveSegment) { - if (!activeSegment) { - messageStore.addError('No active segment selected'); - return; - } - - // Check if the active segment is locked - const segment = segmentGroupStore.getSegment(groupId, activeSegment); - if (segment?.locked) { - messageStore.addError('Cannot process locked segment'); - return; - } - } - - const segImage = segmentGroupStore.dataIndex[groupId]; - const activeParentImageID = - segmentGroupStore.metadataByID[groupId].parentImage; - const processType = activeProcessType.value; - const processRunId = ++activeProcessRunId; - - const originalScalars = segImage - .getPointData() - .getScalars() - .getData() - .slice(); - - paintStore.enterProcessMode(); - processState.value = { - step: 'computing', - activeParentImageID, - activeSegmentGroupID: groupId, - processType, - }; - - try { - const outputScalars = await algorithm(segImage, activeSegment ?? 0); - - // If the state changed during the async operation, stop processing. - if ( - processRunId !== activeProcessRunId || - processState.value.step !== 'computing' - ) { - return; - } - - const scalars = segImage.getPointData().getScalars(); - scalars.setData(outputScalars); - segImage.modified(); - - processState.value = { - step: 'previewing', - activeParentImageID, - activeSegmentGroupID: groupId, - processType, - segImage, - originalScalars, - processedScalars: outputScalars, - showingOriginal: false, - }; - } catch (error) { - if ( - processRunId !== activeProcessRunId || - processState.value.step !== 'computing' - ) { - return; - } - - messageStore.addError(`${processType} Operation Failed`, { - error: error as Error, - }); - rollbackPreview(segImage, originalScalars); - resetState(); - paintStore.restoreModeAfterProcess(); - } - } - - function togglePreview() { - const state = processState.value; - - if (state.step === 'previewing') { - const newShowingOriginal = !state.showingOriginal; - const scalarsToShow = newShowingOriginal - ? state.originalScalars - : state.processedScalars; - - state.segImage.getPointData().getScalars().setData(scalarsToShow); - state.segImage.modified(); - - processState.value = { - ...state, - showingOriginal: newShowingOriginal, - }; - } - } - - watch( - () => paintStore.activeMode, - (mode, previousMode) => { - if (previousMode !== PaintMode.Process || mode === PaintMode.Process) { - return; - } - const state = processState.value; - if (state.step !== 'computing' && state.step !== 'previewing') { - return; - } - cancelProcess(); - } - ); - - // Cancel process when active segment group changes - watch(activeSegmentGroupID, (groupId) => { - const state = processState.value; - if (state.step !== 'computing' && state.step !== 'previewing') { - return; - } - if (state.activeSegmentGroupID === groupId) { - return; - } - cancelProcess(); - }); - - // Cancel process when current image changes - watch(currentImageID, (newVal) => { - const state = processState.value; - if ( - (state.step === 'computing' || state.step === 'previewing') && - state.activeParentImageID !== newVal - ) { - cancelProcess(); - } - }); - - return { - processState, - processStep, - activeProcessType, - showingOriginal, - setActiveProcessType, - startProcess, - confirmProcess, - cancelProcess, - togglePreview, - resetState, - }; -}); diff --git a/src/store/tools/polygons.ts b/src/store/tools/polygons.ts index 410e3650b..e643c1cef 100644 --- a/src/store/tools/polygons.ts +++ b/src/store/tools/polygons.ts @@ -6,11 +6,11 @@ import { useToolSelectionStore, } from '@/src/store/tools/toolSelection'; import { AnnotationToolType } from '@/src/store/tools/types'; -import { POLYGON_LABEL_DEFAULTS } from '@/src/config'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; import { getPlaneTransforms } from '@/src/utils/frameOfReference'; import { ToolID } from '@/src/types/annotation-tool'; import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -38,7 +38,8 @@ const ensureVec2 = (regions: (Vec2 | Vec6)[][]) => { export const usePolygonStore = defineAnnotationToolStore('polygon', () => { const toolAPI = useAnnotationTool({ toolDefaults, - initialLabels: POLYGON_LABEL_DEFAULTS, + segments: () => useSegmentStore().segments, + manifestKey: 'polygons', }); function getPoints(id: ToolID) { @@ -129,14 +130,14 @@ export const usePolygonStore = defineAnnotationToolStore('polygon', () => { return mergedTool; }; - const sameSliceAndLabel = (a: Tool, b: Tool) => - a.label === b.label && + const sameSliceAndSegment = (a: Tool, b: Tool) => + a.segmentId === b.segmentId && a.slice === b.slice && a.frame === b.frame && a.frameOfReference === b.frameOfReference; const mergable = (a: Tool, b: Tool) => { - if (!sameSliceAndLabel(a, b)) return false; + if (!sameSliceAndSegment(a, b)) return false; return polygonsOverlap(a, b); }; // --- // @@ -194,8 +195,12 @@ export const usePolygonStore = defineAnnotationToolStore('polygon', () => { state.manifest.tools.polygons = toolAPI.serializeTools(); } - function deserialize(manifest: Manifest, dataIDMap: Record) { - toolAPI.deserializeTools(manifest.tools?.polygons, dataIDMap); + function deserialize( + manifest: Manifest, + dataIDMap: Record, + segmentIdMap: Record = {} + ) { + toolAPI.deserializeTools(manifest.tools?.polygons, dataIDMap, segmentIdMap); } return { diff --git a/src/store/tools/rectangles.ts b/src/store/tools/rectangles.ts index 24d028e68..23207e280 100644 --- a/src/store/tools/rectangles.ts +++ b/src/store/tools/rectangles.ts @@ -1,9 +1,9 @@ import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore'; import type { Vector3 } from '@kitware/vtk.js/types'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; -import { RECTANGLE_LABEL_DEFAULTS } from '@/src/config'; import { ToolID } from '@/src/types/annotation-tool'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -19,15 +19,11 @@ const rectangleDefaults = () => ({ fillColor: 'transparent', }); -const newLabelDefault = { - fillColor: 'transparent', -}; - export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { const toolAPI = useAnnotationTool({ toolDefaults: rectangleDefaults, - initialLabels: RECTANGLE_LABEL_DEFAULTS, - newLabelDefault, + segments: () => useSegmentStore().segments, + manifestKey: 'rectangles', }); function getPoints(id: ToolID) { @@ -42,8 +38,16 @@ export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { state.manifest.tools.rectangles = toolAPI.serializeTools(); } - function deserialize(manifest: Manifest, dataIDMap: Record) { - toolAPI.deserializeTools(manifest.tools?.rectangles, dataIDMap); + function deserialize( + manifest: Manifest, + dataIDMap: Record, + segmentIdMap: Record = {} + ) { + toolAPI.deserializeTools( + manifest.tools?.rectangles, + dataIDMap, + segmentIdMap + ); } return { diff --git a/src/store/tools/rulers.ts b/src/store/tools/rulers.ts index cf02ca1a6..f4c01a1c6 100644 --- a/src/store/tools/rulers.ts +++ b/src/store/tools/rulers.ts @@ -4,9 +4,9 @@ import type { Vector3 } from '@kitware/vtk.js/types'; import { distance2BetweenPoints } from '@kitware/vtk.js/Common/Core/Math'; import { ToolID } from '@/src/types/annotation-tool'; -import { RULER_LABEL_DEFAULTS } from '@/src/config'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -24,7 +24,8 @@ const rulerDefaults = () => ({ export const useRulerStore = defineAnnotationToolStore('ruler', () => { const annotationTool = useAnnotationTool({ toolDefaults: rulerDefaults, - initialLabels: RULER_LABEL_DEFAULTS, + segments: () => useSegmentStore().segments, + manifestKey: 'rulers', }); // prefix some props with ruler @@ -62,12 +63,16 @@ export const useRulerStore = defineAnnotationToolStore('ruler', () => { state.manifest.tools.rulers = serializeTools(); } - function deserialize(manifest: Manifest, dataIDMap: Record) { - deserializeTools(manifest.tools?.rulers, dataIDMap); + function deserialize( + manifest: Manifest, + dataIDMap: Record, + segmentIdMap: Record = {} + ) { + deserializeTools(manifest.tools?.rulers, dataIDMap, segmentIdMap); } return { - ...annotationTool, // support useAnnotationTool interface (for MeasurementsToolList) + ...annotationTool, rulerIDs, rulerByID, rulers, diff --git a/src/store/tools/types.ts b/src/store/tools/types.ts index 6919c118c..04f580e67 100644 --- a/src/store/tools/types.ts +++ b/src/store/tools/types.ts @@ -27,7 +27,13 @@ export interface IActivatableTool { export interface ISerializableTool { serialize: (state: StateFile) => void; - deserialize: (manifest: Manifest, dataIDMap: Record) => void; + deserialize: ( + manifest: Manifest, + dataIDMap: Record, + // Save-time type id -> restored type id, for the tools that share the + // delineation registry. + segmentIdMap?: Record + ) => void; } export interface IToolStore diff --git a/src/store/tools/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index d9a348913..64598e21a 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -1,10 +1,6 @@ -import { Ref, computed, ref, watch } from 'vue'; +import { Ref, computed, markRaw, ref } from 'vue'; import type { Vector3 } from '@kitware/vtk.js/types'; import type { Maybe, PartialWithRequired, UnwrapAll } from '@/src/types'; -import { - STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, - TOOL_COLORS, -} from '@/src/config'; import { isRecord, removeFromArray } from '@/src/utils'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { onImageDeleted } from '@/src/composables/onImageDeleted'; @@ -14,35 +10,44 @@ import { useIdStore } from '@/src/store/id'; import { useToolSelectionStore } from '@/src/store/tools/toolSelection'; import type { IToolStore } from '@/src/store/tools/types'; import { applyLocator } from '@/src/core/annotations/locator'; -import { useLabels, type Labels } from './useLabels'; +import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; +import { declareSegmentReferences } from '@/src/segmentation/segmentReferences'; // Shared manifest-ref declaration for the annotation-tool stores. Each store // calls this at module scope next to its serialize, pairing the dev-backstop // coverage with the onImageDeleted cascade this composable registers. -export const declareAnnotationToolManifestRefs = ( - key: 'rulers' | 'rectangles' | 'polygons' -) => +export type AnnotationToolKey = 'rulers' | 'rectangles' | 'polygons'; + +export const declareAnnotationToolManifestRefs = (key: AnnotationToolKey) => declareManifestRefs(`tools.${key}`, (manifest) => { const tools = isRecord(manifest.tools) ? manifest.tools : {}; const section = tools[key]; if (!isRecord(section) || !Array.isArray(section.tools)) return []; - return section.tools.flatMap((entry, index) => - isRecord(entry) && typeof entry.imageID === 'string' - ? [ - { - kind: 'dataset' as const, - id: entry.imageID, - where: `tools.${key}[${index}].imageID`, - }, - ] - : [] - ); + return section.tools.flatMap((entry, index) => { + if (!isRecord(entry)) return []; + return [ + ...(typeof entry.imageID === 'string' + ? [ + { + kind: 'dataset' as const, + id: entry.imageID, + where: `tools.${key}[${index}].imageID`, + }, + ] + : []), + ...(typeof entry.segmentId === 'string' && entry.segmentId + ? [ + { + kind: 'segment' as const, + id: entry.segmentId, + where: `tools.${key}[${index}].segmentId`, + }, + ] + : []), + ]; + }); }); -const annotationToolLabelDefault = Object.freeze({ - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT as number, -}); - const makeAnnotationToolDefaults = () => ({ frameOfReference: { planeOrigin: [0, 0, 0], @@ -51,23 +56,23 @@ const makeAnnotationToolDefaults = () => ({ slice: -1, imageID: '', placing: false, - color: TOOL_COLORS[0], - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + segmentId: '', name: 'baseAnnotationTool', }); // Must return addTool in consuming Pinia store. export const useAnnotationTool = < MakeToolDefaults extends (...args: any) => any, - LabelProps, >({ toolDefaults, - initialLabels, - newLabelDefault, + segments, + manifestKey, }: { toolDefaults: MakeToolDefaults; - initialLabels: Labels; - newLabelDefault?: LabelProps; + // Factory, not the invoked registry: tools are created inside store setup. + segments: () => SegmentRegistry; + // The manifest section this tool owns, which is also its reference-holder id. + manifestKey: AnnotationToolKey; }) => { type ToolDefaults = ReturnType; type Tool = ToolDefaults & AnnotationTool; @@ -88,21 +93,7 @@ export const useAnnotationTool = < tools.value.filter((tool): tool is FinishedTool => !tool.placing) ); - const labels = useLabels({ - ...annotationToolLabelDefault, - ...newLabelDefault, - }); - labels.mergeLabels(initialLabels); - - function makePropsFromLabel(label: string | undefined) { - if (!label) return { labelName: '' }; - - const labelProps = labels.labels.value[label]; - if (labelProps) return labelProps; - - // if label deleted, remove label name from tool - return { labelName: '' }; - } + const registry = segments(); function addTool(tool: ToolPatch): ToolID { const id = useIdStore().nextId() as ToolID; @@ -113,10 +104,8 @@ export const useAnnotationTool = < toolByID.value[id] = { ...makeAnnotationToolDefaults(), ...toolDefaults(), - label: labels.activeLabel.value, + segmentId: registry.selectedSegmentId.value ?? '', ...tool, - // updates label props if changed between sessions - ...makePropsFromLabel(tool.label), id, }; @@ -124,6 +113,10 @@ export const useAnnotationTool = < return id; } + /** The appearance a tool draws with, resolved from its segment. */ + const appearanceOfTool = (id: ToolID) => + registry.appearanceOf(toolByID.value[id]?.segmentId); + function removeTool(id: ToolID) { if (!(id in toolByID.value)) return; @@ -140,10 +133,29 @@ export const useAnnotationTool = < toolByID.value[id] = { ...toolByID.value[id], ...patch, id }; } + // Starting an annotation is the gesture that names the segment it delineates: + // one begun against nothing mints and selects a segment the way a first paint + // stroke does, so it is drawn in that segment's color while it is still being + // placed. Idempotent, since the tool then names a live segment. + function resolveToolType(id: ToolID) { + const tool = toolByID.value[id]; + if (!tool || registry.getSegment(tool.segmentId)) return; + updateTool(id, { + segmentId: registry.ensureSelectedSegment(), + } as ToolPatch); + } + + // Placing resolves too, for an annotation that arrived without one of the + // gestures that would have. + function placeTool(id: ToolID) { + resolveToolType(id); + updateTool(id, { placing: false } as ToolPatch); + } + // Delete-base cleanup: a removed image's tools // must not linger — they are invisible in the UI (tool lists filter to the // current image) and an orphaned imageID in the next save manifest is the - // backend's intentional fail-closed 400. Mirrors the segment-group cascade. + // backend's intentional fail-closed 400. Mirrors the segmentation cascade. onImageDeleted((deletedIDs) => { const deleted = new Set(deletedIDs); toolIDs.value @@ -151,15 +163,6 @@ export const useAnnotationTool = < .forEach((id) => removeTool(id)); }); - // updates props controlled by labels - watch(labels.labels, () => { - toolIDs.value.forEach((id) => { - const tool = toolByID.value[id]; - const propsFromLabel = makePropsFromLabel(tool.label); - updateTool(id, { ...tool, ...propsFromLabel }); - }); - }); - const { currentImageID } = useCurrentImage('global'); function jumpToTool(toolID: ToolID) { @@ -180,44 +183,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]; - }) - ); - serialized?.tools + .filter(({ segmentId }) => { + const mappedId = segmentId && segmentIdMap[segmentId]; + return !mappedId || registry.getSegment(mappedId); + }) + // An image that did not load leaves its annotations with nothing to hang + // on: they cannot be drawn, and seating them with a missing image would + // make the next save's whole tools section invalid. + .filter(({ imageID }) => dataIDMap[imageID] !== undefined) .map( - ({ imageID, label, ...rest }) => + ({ imageID, segmentId, ...rest }) => ({ ...rest, imageID: dataIDMap[imageID], - label: (label && labelIDMap[label]) || '', + segmentId: (segmentId && segmentIdMap[segmentId]) || '', }) as ToolPatch ) .forEach((tool) => addTool(tool)); } + // A tool still being placed is the widget's own stub, not content: taking it + // with a deleted segment would leave the widget holding a dead id and no way + // to place anything, and placing re-resolves the segment anyway. + const referencesSegment = (id: ToolID, segmentId: string) => { + const tool = toolByID.value[id]; + return tool.segmentId === segmentId && !tool.placing; + }; + + // Shapes reference a segment; deleting one takes its shapes with it. + const removeToolsOfSegment = (segmentId: string) => + toolIDs.value + .filter((id) => referencesSegment(id, segmentId)) + .forEach((id) => removeTool(id)); + + const hasToolsOfSegment = (segmentId: string) => + toolIDs.value.some((id) => referencesSegment(id, segmentId)); + + declareSegmentReferences(manifestKey, { + has: hasToolsOfSegment, + remove: removeToolsOfSegment, + }); + return { - ...labels, + segments: markRaw(registry), + appearanceOfTool, + removeToolsOfSegment, + hasToolsOfSegment, toolIDs, toolByID, tools, @@ -225,6 +251,8 @@ export const useAnnotationTool = < addTool, removeTool, updateTool, + resolveToolType, + placeTool, jumpToTool, serializeTools, deserializeTools, @@ -234,7 +262,7 @@ export const useAnnotationTool = < type ToolFactory = (...args: any[]) => T; export type AnnotationToolAPI = ReturnType< - typeof useAnnotationTool, any> + typeof useAnnotationTool> > & { getPoints(id: ToolID): Vector3[]; }; diff --git a/src/store/tools/useLabels.ts b/src/store/tools/useLabels.ts deleted file mode 100644 index 4731d4074..000000000 --- a/src/store/tools/useLabels.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Maybe, UnwrapAll } from '@/src/types'; -import { ref } from 'vue'; -import { TOOL_COLORS } from '@/src/config'; -import { useIdStore } from '../id'; - -const labelDefault = Object.freeze({ - labelName: 'New Label' as string, - color: TOOL_COLORS[0] as string, -}); - -export type Label = Partial; -export type Labels = Record>; - -type LabelID = string; - -// param newLabelDefault should contain all label controlled props -// of the tool so placing tool does hold any last active label props. -export const useLabels = (newLabelDefault: Props) => { - type ToolLabel = Label; - type ToolLabels = Labels; - - const labels = ref({}); - - const activeLabel = ref(); - // Accepts undefined so a caller that must not disturb the picker — applying a - // job's annotations result — can put back an activeLabel that was never set. - const setActiveLabel = (id: string | undefined) => { - activeLabel.value = id; - }; - - let nextToolColorIndex = 0; - - const addLabel = (label: ToolLabel = {}) => { - const id = useIdStore().nextId(); - labels.value[id] = { - ...labelDefault, - ...newLabelDefault, - color: TOOL_COLORS[nextToolColorIndex], - ...label, - }; - - nextToolColorIndex = (nextToolColorIndex + 1) % TOOL_COLORS.length; - - setActiveLabel(id); - return id; - }; - - const deleteLabel = (id: LabelID) => { - if (!(id in labels.value)) throw new Error('Label does not exist'); - - delete labels.value[id]; - labels.value = { ...labels.value }; // trigger reactive update for measurement list - - // pick another active label if deleted was active - if (id === activeLabel.value) { - const labelIDs = Object.keys(labels.value); - if (labelIDs.length !== 0) setActiveLabel(labelIDs[0]); - else setActiveLabel(''); - } - }; - - const updateLabel = (id: LabelID, patch: ToolLabel) => { - if (!(id in labels.value)) throw new Error('Label does not exist'); - - labels.value = { ...labels.value, [id]: { ...labels.value[id], ...patch } }; - }; - - // Flag to indicate if should clear existing labels - const defaultLabels = ref(true); - - const clearDefaultLabels = () => { - if (defaultLabels.value) labels.value = {}; - defaultLabels.value = false; - }; - - const findLabel = (name: Maybe) => { - return Object.entries(labels.value).find( - ([, { labelName }]) => name === labelName - ); - }; - - /* - * If input label has the same name as existing label, update existing label with input label properties. - * - * param label: label to merge - * param clearDefault: if true, clear initial labels, do nothing if initial labels already cleared - */ - const mergeLabel = (label: ToolLabel) => { - const { labelName } = label; - const matchingName = findLabel(labelName); - - if (matchingName) { - const [existingID] = matchingName; - updateLabel(existingID, label); - return existingID; - } - - return addLabel(label); - }; - - /* - * If input label has the same name as existing label, update existing label with input label properties. - * - * param newLabels: each key is the label name - * param clearDefault: if true, clear initial labels, do nothing if initial labels already cleared - */ - const mergeLabels = (newLabels: Maybe) => { - Object.entries(newLabels ?? {}).forEach(([labelName, props]) => - mergeLabel({ ...props, labelName }) - ); - }; - - return { - labels, - activeLabel, - setActiveLabel, - addLabel, - deleteLabel, - updateLabel, - // Exposed for callers that need the merged label's id back — applying a - // job's annotations result maps wire label NAMES to store label ids. - mergeLabel, - mergeLabels, - findLabel, - clearDefaultLabels, - }; -}; - -export type LabelsStore = UnwrapAll>>; diff --git a/src/store/view-configs/layers.ts b/src/store/view-configs/layers.ts index b01f77a9b..11ea9abf1 100644 --- a/src/store/view-configs/layers.ts +++ b/src/store/view-configs/layers.ts @@ -27,7 +27,7 @@ function getPreset(id: string) { const layersStore = useLayersStore(); const layer = layersStore.getLayer(id); if (!layer) { - // Return default preset if layer not found (e.g., for segment groups) + // Return default preset if layer not found (e.g., for segmentations) return LAYER_PRESET_DEFAULT; } diff --git a/src/store/view-configs/segmentGroups.ts b/src/store/view-configs/segmentGroups.ts deleted file mode 100644 index 7bbf320b2..000000000 --- a/src/store/view-configs/segmentGroups.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { reactive, computed, unref, MaybeRef } from 'vue'; -import { defineStore } from 'pinia'; - -import { - DoubleKeyRecord, - deleteSecondKey, - getDoubleKeyRecord, - patchDoubleKeyRecord, -} from '@/src/utils/doubleKeyRecord'; -import { Maybe } from '@/src/types'; - -import { createViewConfigSerializer } from '@/src/store/view-configs/common'; -import { ViewConfig } from '@/src/io/state-file/schema'; -import { SegmentGroupConfig } from '@/src/store/view-configs/types'; -import { useViewStore } from '@/src/store/views'; - -type Config = SegmentGroupConfig; -const CONFIG_NAME = 'segmentGroup'; - -export const defaultConfig = () => ({ - outlineOpacity: 1.0, - outlineThickness: 2, -}); - -export const useSegmentGroupConfigStore = defineStore( - `${CONFIG_NAME}Config`, - () => { - const configs = reactive>({}); - - const getConfig = (viewID: Maybe, dataID: Maybe) => - getDoubleKeyRecord(configs, viewID, dataID) ?? defaultConfig(); - - const updateConfig = ( - viewID: string, - dataID: string, - patch: Partial - ) => { - const config = { - ...defaultConfig(), - ...getConfig(viewID, dataID), - ...patch, - }; - - patchDoubleKeyRecord(configs, viewID, dataID, config); - }; - - const removeView = (viewID: string) => { - delete configs[viewID]; - }; - - const removeData = (dataID: string, viewID?: string) => { - if (viewID) { - delete configs[viewID]?.[dataID]; - } else { - deleteSecondKey(configs, dataID); - } - }; - - const serialize = createViewConfigSerializer(configs, CONFIG_NAME); - - const deserialize = ( - viewID: string, - config: Record - ) => { - Object.entries(config).forEach(([dataID, viewConfig]) => { - if (viewConfig.segmentGroup) { - updateConfig(viewID, dataID, viewConfig.segmentGroup); - } - }); - }; - - // For updating all configs together // - - const aConfig = computed(() => { - const viewIDs = Object.keys(configs); - if (viewIDs.length === 0) return null; - const firstViewID = viewIDs[0]; - const dataIDs = Object.keys(configs[firstViewID]); - if (dataIDs.length === 0) return null; - const firstDataID = dataIDs[0]; - return configs[firstViewID][firstDataID]; - }); - - const updateAllConfigs = (dataID: string, patch: Partial) => { - Object.keys(configs).forEach((viewID) => { - updateConfig(viewID, dataID, patch); - }); - }; - - return { - configs, - getConfig, - updateConfig, - removeView, - removeData, - serialize, - deserialize, - aConfig, - updateAllConfigs, - }; - } -); - -export const useGlobalSegmentGroupConfig = (dataId: MaybeRef) => { - const store = useSegmentGroupConfigStore(); - const viewStore = useViewStore(); - - const views = computed(() => - viewStore.getAllViews().filter((view) => view.type === '2D') - ); - - const configs = computed(() => - views.value.map((view) => ({ - config: store.getConfig(view.id, unref(dataId)), - viewID: view.id, - })) - ); - - // get any one - const config = computed(() => configs.value.find(({ config: c }) => c)); - - // update all configs - const updateConfig = (patch: Partial) => { - configs.value.forEach(({ viewID }) => - store.updateConfig(viewID, unref(dataId), patch) - ); - }; - - return { config, updateConfig }; -}; diff --git a/src/store/view-configs/types.ts b/src/store/view-configs/types.ts index f81d5498e..6503e2fbd 100644 --- a/src/store/view-configs/types.ts +++ b/src/store/view-configs/types.ts @@ -51,11 +51,6 @@ export interface LayersConfig { blendConfig: BlendConfig; } -export interface SegmentGroupConfig { - outlineOpacity: number; - outlineThickness: number; -} - export interface CinePlaybackViewConfig { frame: number; } diff --git a/src/types/annotation-tool.ts b/src/types/annotation-tool.ts index e487965bd..4358e2445 100644 --- a/src/types/annotation-tool.ts +++ b/src/types/annotation-tool.ts @@ -23,11 +23,8 @@ export type AnnotationTool = { */ placing?: boolean; - label?: string; - labelName?: string; - - color: string; - strokeWidth?: number; + /** The segment this annotation delineates, in its own registry. */ + segmentId?: string; name: string; diff --git a/src/types/segment.ts b/src/types/segment.ts deleted file mode 100644 index f530763af..000000000 --- a/src/types/segment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RGBAColor } from '@kitware/vtk.js/types'; - -export interface SegmentMask { - value: number; - name: string; - color: RGBAColor; - visible: boolean; - locked?: boolean; -} diff --git a/src/utils/__tests__/allocateImageFromChunks.spec.ts b/src/utils/__tests__/allocateImageFromChunks.spec.ts index a47bb2be8..a9d4878db 100644 --- a/src/utils/__tests__/allocateImageFromChunks.spec.ts +++ b/src/utils/__tests__/allocateImageFromChunks.spec.ts @@ -236,7 +236,7 @@ describe('getTypedArrayValueRange', () => { }); }); - it('has no range to report for element types the allocator never makes', () => { + it('has no range to report for element segments the allocator never makes', () => { expect(getTypedArrayValueRange(Float32Array)).toBeUndefined(); }); }); diff --git a/src/utils/bugReport.ts b/src/utils/bugReport.ts index 59181ba62..76c47df27 100644 --- a/src/utils/bugReport.ts +++ b/src/utils/bugReport.ts @@ -3,7 +3,7 @@ import { useDatasetStore } from '@/src/store/datasets'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useImageCacheStore } from '@/src/store/image-cache'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/segmentation/store'; import { COMPOUND_EXTENSIONS } from '@/src/utils/path'; const MAX_ERROR_LENGTH = 4000; @@ -35,7 +35,7 @@ const collectDatasetInfo = (): string[] => { const datasetStore = useDatasetStore(); const imageCacheStore = useImageCacheStore(); const dicomStore = useDICOMStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); return datasetStore.idsAsSelections.map((id, i) => { const metadata = imageCacheStore.getImageMetadata(id); @@ -57,10 +57,11 @@ const collectDatasetInfo = (): string[] => { ? 'DICOM' : 'unknown'; - const segCount = segmentGroupStore.orderByParent[id]?.length ?? 0; + const segCount = + segmentationStore.getSegmentationForImage(id)?.order.length ?? 0; const segPart = segCount > 0 - ? ` (segment groups: ${segCount} as ${segmentGroupStore.saveFormat})` + ? ` (segments: ${segCount} as ${segmentationStore.saveFormat})` : ''; return ` [${i}] ${dims} ${dataType} from ${sourceFormat}${segPart}`; @@ -81,11 +82,11 @@ export const generateBugReport = (error?: Error): string => { ]; const datasets = collectDatasetInfo(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); lines.push('', `Datasets: ${datasets.length}`); lines.push(...datasets); - lines.push(`Save format: ${segmentGroupStore.saveFormat}`); + lines.push(`Save format: ${segmentationStore.saveFormat}`); lines.push('--- End Report ---'); diff --git a/src/utils/color.ts b/src/utils/color.ts index e71f644ea..80ca63bba 100644 --- a/src/utils/color.ts +++ b/src/utils/color.ts @@ -1,5 +1,15 @@ import type { RGBAColor } from '@kitware/vtk.js/types'; +/** A cursor over a palette: each call hands out the next opaque colour. */ +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..b6d990c5c 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); }; + +/** + * How a selection reads as a name for something derived from it. A DICOM + * display name is not a filename, so only a file-backed selection is stripped. + */ +export const getSelectionStem = (selection: string) => { + const name = getSelectionName(selection); + if (!name) return undefined; + return isRegularImage(selection) ? stripExtension(name) : name; +}; diff --git a/src/vtk/LabelMap/index.d.ts b/src/vtk/LabelMap/index.d.ts index df8c3c378..1716092da 100644 --- a/src/vtk/LabelMap/index.d.ts +++ b/src/vtk/LabelMap/index.d.ts @@ -1,25 +1,11 @@ -import { SegmentMask } from '@/src/types/segment'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import type { Vector4 } from '@kitware/vtk.js/types'; +/** + * SegmentMask voxel storage. Its own class so a mask stays distinguishable from + * the image it sits on, in the type system and in serialized state alike. + */ export interface vtkLabelMap extends vtkImageData { - /** - * Sets the segments of the labelmap. - * @param segments - */ - setSegments(segments: SegmentMask[]): boolean; - - /** - * Gets the segments of the labelmap. - */ - getSegments(): SegmentMask[]; - - /** - * Replaces a labelmap value with another value. - * @param from - * @param to - */ - replaceLabelValue(from: number, to: number): void; + getClassName(): 'vtkLabelMap'; } export function newInstance(initialValues?: any): vtkLabelMap; diff --git a/src/vtk/LabelMap/index.js b/src/vtk/LabelMap/index.js index 0c8acf6af..1dad3cc25 100644 --- a/src/vtk/LabelMap/index.js +++ b/src/vtk/LabelMap/index.js @@ -1,54 +1,13 @@ import macro from '@kitware/vtk.js/macro'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import deepEqual from 'deep-equal'; - -// ---------------------------------------------------------------------------- -// vtkLabelMap methods -// ---------------------------------------------------------------------------- - -function vtkLabelMap(publicAPI, model) { - // Set our className - model.classHierarchy.push('vtkLabelMap'); - - const originalAPI = { ...publicAPI }; - - publicAPI.replaceLabelValue = (from, to) => { - const pixels = publicAPI.getPointData().getScalars().getData(); - const len = pixels.length; - for (let i = 0; i < len; i++) { - if (pixels[i] === from) { - pixels[i] = to; - } - } - }; - - publicAPI.setSegments = (segments) => { - if (segments === model.segments || deepEqual(segments, model.segments)) { - return false; - } - return originalAPI.setSegments(segments); - }; -} // ---------------------------------------------------------------------------- // Object factory // ---------------------------------------------------------------------------- -const defaultValues = () => ({ - segments: [], -}); - -// ---------------------------------------------------------------------------- - export function extend(publicAPI, model, initialValues = {}) { - Object.assign(model, defaultValues(), initialValues); - vtkImageData.extend(publicAPI, model, initialValues); - - macro.setGet(publicAPI, model, ['segments']); - - // Object specific methods - vtkLabelMap(publicAPI, model); + model.classHierarchy.push('vtkLabelMap'); } // ---------------------------------------------------------------------------- diff --git a/src/vtk/PaintWidget/behavior.ts b/src/vtk/PaintWidget/behavior.ts index 6514aca03..77d780cf7 100644 --- a/src/vtk/PaintWidget/behavior.ts +++ b/src/vtk/PaintWidget/behavior.ts @@ -11,15 +11,26 @@ export default function widgetBehavior(publicAPI: any, model: any) { const getWorldCoords = computeWorldCoords(model); // support setting per-view widget manipulators - macro.setGet(publicAPI, model, ['manipulator']); + macro.setGet(publicAPI, model, ['manipulator', 'sampling']); let isPainting = false; + let samplingStroke = false; + + const setSampling = publicAPI.setSampling; + publicAPI.setSampling = (sampling: boolean) => { + // Once a gesture samples, it cannot resume writing before a fresh press. + if (sampling && isPainting) samplingStroke = true; + return setSampling(sampling); + }; /** * Starts painting */ publicAPI.handleLeftButtonPress = (eventData: any) => { - if (!model.manipulator || shouldIgnoreEvent(eventData)) { + if ( + !model.manipulator || + (!model.sampling && shouldIgnoreEvent(eventData)) + ) { return macro.VOID; } @@ -32,7 +43,8 @@ export default function widgetBehavior(publicAPI: any, model: any) { brush.setOrigin(...worldCoords); isPainting = true; - publicAPI.invokeStartInteractionEvent(); + samplingStroke = !!model.sampling; + publicAPI.invokeStartInteractionEvent({ sampling: samplingStroke }); return macro.EVENT_ABORT; }; @@ -40,7 +52,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { * Paints */ publicAPI.handleMouseMove = (eventData: any) => { - if (shouldIgnoreEvent(eventData)) { + if (isPainting && !model.sampling && shouldIgnoreEvent(eventData)) { return macro.VOID; } @@ -54,7 +66,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { brush.setOrigin(...worldCoords); if (isPainting) { - publicAPI.invokeInteractionEvent(); + if (!samplingStroke) publicAPI.invokeInteractionEvent(); return macro.EVENT_ABORT; } @@ -65,13 +77,13 @@ export default function widgetBehavior(publicAPI: any, model: any) { /** * Finishes paint */ - publicAPI.handleLeftButtonRelease = (eventData: any) => { - if (!isPainting || shouldIgnoreEvent(eventData)) { + publicAPI.handleLeftButtonRelease = () => { + if (!isPainting) { return macro.VOID; } isPainting = false; - publicAPI.invokeEndInteractionEvent(); + if (!samplingStroke) publicAPI.invokeEndInteractionEvent(); return macro.EVENT_ABORT; }; @@ -81,20 +93,4 @@ export default function widgetBehavior(publicAPI: any, model: any) { } return macro.VOID; }; - - publicAPI.grabFocus = () => { - if (!model.hasFocus) { - model.hasFocus = true; - model._interactor.requestAnimation(publicAPI); - } - }; - - publicAPI.loseFocus = () => { - if (model.hasFocus) { - model._interactor.cancelAnimation(publicAPI); - } - model.hasFocus = false; - // model._widgetManager.enablePicking(); - // model._interactor.render(); - }; } diff --git a/src/vtk/PaintWidget/index.d.ts b/src/vtk/PaintWidget/index.d.ts index a748ed822..4e8946021 100644 --- a/src/vtk/PaintWidget/index.d.ts +++ b/src/vtk/PaintWidget/index.d.ts @@ -5,6 +5,7 @@ import { mat4, vec3 } from 'gl-matrix'; import { PaintWidgetState } from './state'; export interface vtkPaintViewWidget extends vtkAbstractWidget { + setSampling(sampling: boolean): boolean; setManipulator(manipulator: vtkPlaneManipulator): boolean; getManipulator(): vtkPlaneManipulator; setSlicingIndex(index: number): boolean; diff --git a/src/vtk/RulerWidget/__tests__/behavior.spec.ts b/src/vtk/RulerWidget/__tests__/behavior.spec.ts new file mode 100644 index 000000000..25c3b02f5 --- /dev/null +++ b/src/vtk/RulerWidget/__tests__/behavior.spec.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import macro from '@kitware/vtk.js/macros'; +import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; +import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget'; +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkSelectionNode from '@kitware/vtk.js/Common/DataModel/SelectionNode'; +import vtkRulerWidget from '@/src/vtk/RulerWidget'; +import vtkRectangleWidget from '@/src/vtk/RectangleWidget'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import widgetBehavior, { InteractionState } from '../behavior'; + +const dispose: Array<() => void> = []; +afterEach(() => dispose.splice(0).forEach((cleanup) => cleanup())); +beforeEach(() => setActivePinia(createPinia())); + +// Rectangle uses the ruler's interaction behavior with different representations. +describe.each([ + ['ruler', vtkRulerWidget, useRulerStore], + ['rectangle', vtkRectangleWidget, useRectangleStore], +] as const)('%s handle presses', (_name, widgetFactory, useStore) => { + const createWidget = ( + selectionKind: 'line' | 'handle' | 'empty' = 'line' + ) => { + const id = useStore().addTool({ imageID: 'image' }); + const factory = widgetFactory.newInstance({ id, isPlaced: true }); + const widgetState = factory.getWidgetState(); + const point = widgetState.getFirstPoint(); + point.setActive(true); + const line = vtkActor.newInstance(); + const selection = vtkSelectionNode.newInstance(); + selection.setProperties(selectionKind === 'line' ? { prop: line } : {}); + const managerInitialValues = { + pickingEnabled: false, + selections: selectionKind === 'empty' ? [] : [selection], + }; + const manager = vtkWidgetManager.newInstance(managerInitialValues); + let animationRequests = 0; + const model = { + widgetState, + activeState: point, + representations: [{}, { getActors: () => [line] }], + _widgetManager: manager, + _apiSpecificRenderWindow: { setCursor: () => {} }, + _interactor: { + requestAnimation: () => { + animationRequests += 1; + }, + cancelAnimation: () => {}, + }, + manipulator: { handleEvent: () => ({ worldCoords: [1, 2, 3] }) }, + }; + const widget: any = {}; + macro.obj(widget, model); + vtkAbstractWidget.extend(widget, model); + widgetBehavior(widget, model); + dispose.push(() => { + widget.delete(); + factory.delete(); + manager.delete(); + line.delete(); + selection.delete(); + }); + return { widget, manager, animationRequests: () => animationRequests }; + }; + + it('does not drag a stale handle when a fresh pick has cleared the previous selection', async () => { + const { widget, manager, animationRequests } = createWidget(); + expect(manager.getSelections()).toHaveLength(1); + // The real manager clears selections synchronously before capture resolves. + // With picking disabled it also leaves that valid no-selection state intact. + const pick = manager.getSelectedDataForXY(12, 24); + expect(manager.getSelections()).toBeNull(); + expect(() => widget.handleLeftButtonPress({})).not.toThrow(); + expect(widget.getInteractionState()).toBe(InteractionState.Select); + expect(animationRequests()).toBe(0); + await pick; + }); + + it('starts dragging a handle after the pick has resolved', () => { + const { widget, animationRequests } = createWidget('handle'); + expect(widget.handleLeftButtonPress({})).toBe(macro.EVENT_ABORT); + expect(widget.getInteractionState()).toBe(InteractionState.Dragging); + expect(animationRequests()).toBe(1); + }); + + it.each(['line', 'empty'] as const)( + 'does not drag when the resolved pick is %s', + (selectionKind) => { + const { widget, animationRequests } = createWidget(selectionKind); + expect(widget.handleLeftButtonPress({})).toBe(macro.VOID); + expect(widget.getInteractionState()).toBe(InteractionState.Select); + expect(animationRequests()).toBe(0); + } + ); +}); diff --git a/src/vtk/RulerWidget/behavior.ts b/src/vtk/RulerWidget/behavior.ts index 0676c6c9a..5367fc739 100644 --- a/src/vtk/RulerWidget/behavior.ts +++ b/src/vtk/RulerWidget/behavior.ts @@ -62,13 +62,14 @@ export default function widgetBehavior(publicAPI: any, model: any) { model._interactor.cancelAnimation(publicAPI, true); }; - // Check if mouse is over line segment between handles - const checkOverSegment = () => { - const selections = model._widgetManager.getSelections(); - const overSegment = - selections[0]?.getProperties().prop === - model.representations[1].getActors()[0]; // line representation is second representation - return overSegment; + // A fresh pick can be pending or empty while the old handle stays active. + // Only a resolved pick away from the line permits that handle to drag. + const canDragHandle = () => { + const selected = model._widgetManager.getSelections()?.[0]; + return ( + !!selected && + selected.getProperties().prop !== model.representations[1].getActors()[0] + ); }; // Check if mouse is over fill representation (for hover but not interaction) @@ -148,7 +149,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { model.activeState?.getActive() && model.activeState?.setOrigin && model.pickable && - !checkOverSegment() + canDragHandle() ) { draggingState = model.activeState; publicAPI.setInteractionState(InteractionState.Dragging); diff --git a/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png b/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png index 0b2e24c52..008c54b82 100644 Binary files a/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png and b/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png differ diff --git a/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png b/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png index 45adf1d1e..7ab9b5494 100644 Binary files a/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png and b/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png differ diff --git a/tests/cachedRemoteData.ts b/tests/cachedRemoteData.ts new file mode 100644 index 000000000..0c3ded60b --- /dev/null +++ b/tests/cachedRemoteData.ts @@ -0,0 +1,24 @@ +import { TEST_DATASETS } from '../wdio.shared.conf'; +import { BASE_URL } from './e2ePorts'; + +/** Read cached fixtures through HTTP while preserving remote manifest identities. */ +export async function useCachedRemoteData() { + const urls = Object.fromEntries( + TEST_DATASETS.map(({ url, name }) => [ + url, + new URL(`tmp/${name}`, BASE_URL).href, + ]) + ); + await browser.addInitScript((cached: Record) => { + const fetch = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const local = cached[url]; + const target = + local && input instanceof Request + ? new Request(local, input) + : (local ?? input); + return fetch(target, init); + }; + }, urls); +} diff --git a/tests/fixtures/label-outline/index.html b/tests/fixtures/label-outline/index.html new file mode 100644 index 000000000..2fd264d30 --- /dev/null +++ b/tests/fixtures/label-outline/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/fixtures/label-outline/scene.ts b/tests/fixtures/label-outline/scene.ts new file mode 100644 index 000000000..7eb6fb5a8 --- /dev/null +++ b/tests/fixtures/label-outline/scene.ts @@ -0,0 +1,141 @@ +import '@kitware/vtk.js/Rendering/Profiles/Volume'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { + allocateMask, + reframeMaskScalars, + setMaskScalars, +} from '@/src/segmentation/masks/storage'; +import { maskScalars } from '@/src/segmentation/model'; +import type { Extent3D } from '@/src/segmentation/geometry'; +import { segmentRenderMask } from '@/src/segmentation/rendering/renderMask'; +import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow'; +import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; +import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import { + SEGMENT_ACTOR_OPACITY, + segmentOutlineTables, +} from '@/src/segmentation/rendering/display'; + +const renderer = vtkRenderer.newInstance(); +const renderWindow = vtkRenderWindow.newInstance(); +const view = vtkOpenGLRenderWindow.newInstance(); +renderWindow.addRenderer(renderer); +renderWindow.addView(view); +const container = document.createElement('div'); +container.style.cssText = 'width:200px;height:200px'; +document.body.appendChild(container); +view.setContainer(container); +view.setSize(200, 200); +const params = new URLSearchParams(location.search); +const axis = Number(params.get('axis') ?? 2); +const scanEdge = params.has('scanEdge'); +const parent = vtkImageData.newInstance(); +const dimensions: [number, number, number] = [10, 10, 10]; +dimensions[axis] = 1; +// Truncate only the high face of the first in-plane axis. +const edgeAxis = axis === 0 ? 1 : 0; +if (scanEdge) dimensions[edgeAxis] = 9; +parent.setDimensions(dimensions); +parent.setOrigin([12, -17, 23]); +const extent: Extent3D = [1, 8, 1, 8, 1, 8]; +extent[axis * 2] = 0; +extent[axis * 2 + 1] = 0; +const source = allocateMask(parent, extent); +maskScalars(source).fill(1); +source.modified(); +const mask = params.has('fullGrid') + ? allocateMask(parent, parent.getExtent() as Extent3D) + : segmentRenderMask(source, parent, extent, { axis: axis, index: 0 })!; +if (params.has('fullGrid')) { + setMaskScalars( + mask, + reframeMaskScalars( + maskScalars(source), + extent, + parent.getExtent() as Extent3D + ) + ); +} +const mapper = vtkImageMapper.newInstance(); +mapper.setInputData(mask); +mapper.setSlicingMode(axis); +mapper.setSlice(0); +const actor = vtkImageSlice.newInstance(); +actor.setMapper(mapper); +const property = actor.getProperty(); +property.setInterpolationTypeToNearest(); +property.setOpacity(SEGMENT_ACTOR_OPACITY); +property.setUseLookupTableScalarRange(true); +property.setUseLabelOutline(true); +property.setLabelOutlineThickness([3]); +property.setLabelOutlineOpacity([1]); +const colors = vtkColorTransferFunction.newInstance(); +colors.addRGBPoint(0, 0, 0, 0); +colors.addRGBPoint(1, 1, 0, 0); +colors.addRGBPoint(2, 0, 0, 0); +const opacity = vtkPiecewiseFunction.newInstance(); +opacity.addPoint(0, 0); +opacity.addPoint(1, 0.2); +opacity.addPoint(2, 0); +property.setRGBTransferFunction(0, colors); +property.setScalarOpacity(0, opacity); +renderer.addActor(actor); +const camera = renderer.getActiveCamera(); +camera.setParallelProjection(true); +const focal: [number, number, number] = [3.5, 3.5, 3.5]; +focal[axis] = 0; +const worldFocal = source.indexToWorld(focal); +const position = [...worldFocal] as [number, number, number]; +position[axis] += 10; +camera.setPosition(...position); +camera.setFocalPoint(worldFocal[0], worldFocal[1], worldFocal[2]); +if (axis === 1) camera.setViewUp(0, 0, 1); +camera.setParallelScale(5); +renderer.resetCameraClippingRange(); +// Keep the same actor while changing tables, as Reveal does. +function renderOutline(thickness = 3, outlineOpacity = 1) { + const tables = segmentOutlineTables( + [{ value: 1, name: 'Mask', visible: true, color: [255, 0, 0, 255] }], + thickness, + outlineOpacity + ); + property.setLabelOutlineThickness(tables.thicknesses); + property.setLabelOutlineOpacity(tables.opacities); + renderWindow.render(); + const gl = view.get3DContext({}); + if (!gl) throw new Error('WebGL is required for the outline regression'); + const pixels = new Uint8Array(200 * 200 * 4); + gl.readPixels(0, 0, 200, 200, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const red = (x: number, y: number) => pixels[(y * 200 + x) * 4]; + return { + left: red(21, 100), + right: red(178, 100), + bottom: red(100, 21), + top: red(100, 178), + innerEdge: red(24, 100), + center: red(100, 100), + outside: red(19, 100), + }; +} + +function editMask(value: number) { + maskScalars(source).fill(value); + source.modified(); + segmentRenderMask(source, parent, extent, { axis: axis, index: 0 }); + return renderOutline(); +} + +declare global { + interface Window { + renderOutline: typeof renderOutline; + editMask: typeof editMask; + outlineResult: ReturnType; + } +} +window.renderOutline = renderOutline; +window.editMask = editMask; +window.outlineResult = renderOutline(); diff --git a/tests/fixtures/label-outline/server.mjs b/tests/fixtures/label-outline/server.mjs new file mode 100644 index 000000000..8435bf9f9 --- /dev/null +++ b/tests/fixtures/label-outline/server.mjs @@ -0,0 +1,26 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +// Serve the isolated WebGL scene without the application's HTML entry plugin. +export async function startOutlineFixture() { + const cacheDir = await mkdtemp(resolve(tmpdir(), 'label-outline-')); + const root = fileURLToPath(new URL('../../../', import.meta.url)); + const server = await createServer({ + configFile: false, + root: fileURLToPath(new URL('.', import.meta.url)), + cacheDir, + resolve: { alias: { '@': root } }, + server: { host: '127.0.0.1', fs: { allow: [root] } }, + }); + await server.listen(); + return { + url: server.resolvedUrls.local[0], + async close() { + await server.close(); + await rm(cacheDir, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/pageobjects/volview.page.ts b/tests/pageobjects/volview.page.ts index 699fdf60d..d88222ab3 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(); } @@ -214,18 +214,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 +226,28 @@ class VolViewPage extends Page { return $('span[data-testid="save-session-confirm-button"]'); } - get segmentGroupsTab() { - return $('button.v-tab*=Segment Groups'); + get saveSegmentsButtons() { + return $$('button[data-testid="save-segments-button"]'); } - get segmentGroupSaveButtons() { - return $$('button[data-testid="segment-group-save-button"]'); + get segmentList() { + return $('[data-testid="segment-list"]'); } - get segmentGroupList() { - return $('.segment-group-list'); - } - - get saveSegmentGroupFilenameInput() { + get saveSegmentsFilenameInput() { return this.activeDialog.$('#filename'); } - get saveSegmentGroupConfirmButton() { + get saveSegmentsConfirmButton() { return this.activeDialog.$('button=Save'); } - async clickFirstSegmentGroupSaveButton() { + async clickSaveSegmentsButton() { await browser.waitUntil(async () => { - const buttons = await this.segmentGroupSaveButtons; + const buttons = await this.saveSegmentsButtons; return (await buttons.length) >= 1; }); - const buttons = await this.segmentGroupSaveButtons; + const buttons = await this.saveSegmentsButtons; await buttons[0].scrollIntoView(); await buttons[0].waitForClickable(); await buttons[0].click(); @@ -288,27 +276,10 @@ class VolViewPage extends Page { return fileName; } - async createSegmentGroup(name: string) { - const annotationsTab = await this.annotationsModuleTab; - await annotationsTab.click(); - - const newGroup = await this.newSegmentGroupButton; - await newGroup.waitForClickable(); - await newGroup.click(); - - const input = await this.activeDialogInput; - await input.waitForDisplayed(); - await setValueVueInput(input, name); - await browser.keys([Key.Enter]); - } - - get editLabelButtons() { - return $$('button[data-testid="edit-label-button"]'); - } - - get labelStrokeWidthInput() { - // there should only be one on the screen at any given time - return $('.label-stroke-width-input').$('input'); + get segmentStrokeWidthSlider() { + return $( + '//label[normalize-space()="Stroke Width"]/ancestor::div[contains(@class, "v-slider")][1]//*[@role="slider"]' + ); } get editLabelModalDoneButton() { diff --git a/tests/specs/adaptive-labelmap.e2e.ts b/tests/specs/adaptive-labelmap.e2e.ts new file mode 100644 index 000000000..46bf521e4 --- /dev/null +++ b/tests/specs/adaptive-labelmap.e2e.ts @@ -0,0 +1,124 @@ +import { readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { gunzipSync } from 'node:zlib'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +import { writeManifestToFile, waitForDownload } from './utils'; +import { openAnnotationSegments } from './segmentationTestUtils'; + +const maybeGunzip = (bytes: Buffer) => + bytes.readUInt16BE(0) === 0x1f8b ? gunzipSync(bytes) : bytes; + +const readLabels = (file: string) => { + const bytes = maybeGunzip(readFileSync(join(TEMP_DIR, file))); + const end = bytes.indexOf(Buffer.from('\n\n')); + expect(end).toBeGreaterThan(0); + const header = bytes.subarray(0, end).toString(); + const data = maybeGunzip(bytes.subarray(end + 2)); + const type = header.match(/^type:\s*(.+)$/m)![1].trim(); + const wide = ['unsigned short', 'ushort', 'uint16'].includes(type); + const count = data.length / (wide ? 2 : 1); + const values = Array.from({ length: count }, (_, index) => { + if (!wide) return data[index]; + return header.includes('endian: big') + ? data.readUInt16BE(index * 2) + : data.readUInt16LE(index * 2); + }); + return { + header, + wide, + labels: [...new Set(values.filter(Boolean))].sort((a, b) => a - b), + }; +}; + +const writeCase = (stem: string, labels: number[]) => { + const header = [ + 'NRRD0005', + 'type: unsigned short', + 'dimension: 3', + 'sizes: 16 16 16', + 'space: left-posterior-superior', + 'space origin: (0,0,0)', + 'space directions: (1,0,0) (0,1,0) (0,0,1)', + 'endian: little', + 'encoding: raw', + ]; + const data = Buffer.alloc(4096 * 2); + writeFileSync( + join(TEMP_DIR, `${stem}.nrrd`), + Buffer.concat([Buffer.from(`${header.join('\n')}\n\n`), data]) + ); + labels.forEach((value, index) => { + data.writeUInt16LE(value, index * 2); + header.push( + `Segment${index}_LabelValue:=${value}`, + `Segment${index}_Name:=Region ${value}` + ); + }); + writeFileSync( + join(TEMP_DIR, `${stem}.seg.nrrd`), + Buffer.concat([Buffer.from(`${header.join('\n')}\n\n`), data]) + ); +}; + +const openLabels = async (stem: string, file: string, count: number) => { + await volViewPage.open( + `?urls=[tmp/adaptive-labelmap-config.json,tmp/${stem}.nrrd,tmp/${file}]` + ); + await volViewPage.waitForViews(); + await openAnnotationSegments(); + await browser.waitUntil( + async () => + (await browser.execute( + () => + document.querySelectorAll( + '[data-testid="segment-list"] .item-row .v-list-item-title' + ).length + )) === count, + { timeoutMsg: `Expected ${count} imported segments` } + ); +}; + +describe('Adaptive labelmap export width', function () { + this.timeout(120_000); + for (const labels of [ + Array.from({ length: 255 }, (_, i) => i + 1), + Array.from({ length: 256 }, (_, i) => i + 1), + [256, 65535], + ]) { + it(`round-trips ${labels.length} labels with ${labels.at(-1)} as the highest input value`, async () => { + const stem = `adaptive-${labels.length}`; + writeCase(stem, labels); + await writeManifestToFile( + { io: { segmentationExtension: 'seg' } }, + 'adaptive-labelmap-config.json' + ); + await openLabels(stem, `${stem}.seg.nrrd`, labels.length); + const saveButton = $('button[data-testid="save-segments-button"]'); + await saveButton.waitForEnabled(); + await browser.execute((button) => button.focus(), await saveButton); + await browser.keys(' '); + await volViewPage.activeDialog.waitForDisplayed(); + await volViewPage.saveSegmentsFilenameInput.waitForDisplayed(); + await expect( + $('[data-testid="save-overlap-notice"]') + ).not.toBeDisplayed(); + const output = `${stem}-export.seg.nrrd`; + rmSync(join(TEMP_DIR, output), { force: true }); + await setValueVueInput( + volViewPage.saveSegmentsFilenameInput, + `${stem}-export` + ); + await volViewPage.saveSegmentsConfirmButton.click(); + await waitForDownload(join(TEMP_DIR, output), 40_000); + const saved = readLabels(output); + expect(saved.wide).toBe(labels.length > 255); + expect(saved.labels).toEqual( + Array.from({ length: labels.length }, (_, i) => i + 1) + ); + expect(saved.header).toContain(`Region ${labels.at(-1)}`); + await openLabels(stem, output, labels.length); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + } +}); diff --git a/tests/specs/annotation-mints-segment.e2e.ts b/tests/specs/annotation-mints-segment.e2e.ts new file mode 100644 index 000000000..2036b3c36 --- /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); + }, + { timeout: 10000, 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 457f7a100..b5ed047ca 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,15 @@ export const setupTest = async () => { }; }; +/** A closed square polygon, drawn corner by corner around a center. */ +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..c779949d0 --- /dev/null +++ b/tests/specs/annotations-sidebar.e2e.ts @@ -0,0 +1,339 @@ +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +import { PROSTATEX_DATASET } from './configTestUtils'; +import { downloadFile, openUrls } from './utils'; +import { + clickAt, + nudgeTo, + pressAtPointer, + rightClickAt, + setupTest, + waitForCircleCount, +} from './annotationTestUtils'; +import { + addSegment, + openAnnotationSegments, + lockSegment, + segmentRow, + openSegmentShapes, + renameSegment, + revealSegment, + segmentListTop, + segmentNames, + selectSegment, + selectedSegmentName, + shapeRowTexts, + waitForNamedSegments, +} from './segmentationTestUtils'; + +// The sidebar sections are stacked rather than tabbed, so the Segments list is +// reachable whatever tool is active. +const DRAWING_TOOLS = [ + 'mdi-vector-square', + 'mdi-pentagon-outline', + 'mdi-ruler', + 'mdi-brush', +]; + +describe('Annotations sidebar', () => { + it('keeps the Segments list in place and selected across tool switches', async () => { + await setupTest(); + + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + + await openAnnotationSegments(); + await waitForNamedSegments(); + await addSegment(); + await renameSegment('Segment 2', 'Lesion'); + await selectSegment('Lesion'); + + const top = await segmentListTop(); + expect(await selectedSegmentName()).toEqual('Lesion'); + + for (const icon of DRAWING_TOOLS) { + await volViewPage.selectTool(icon); + await $('[data-testid="segment-list"]').waitForDisplayed(); + expect(await segmentNames()).toEqual(['Segment 1', 'Lesion']); + expect(await selectedSegmentName()).toEqual('Lesion'); + expect(await segmentListTop()).toEqual(top); + } + }); + + it('selects one segment on ruler activation and uses it for the placement', async () => { + const { centerX, centerY } = await setupTest(); + + await volViewPage.selectTool('mdi-ruler'); + await openAnnotationSegments(); + expect(await segmentNames()).toEqual(['Segment 1']); + expect(await selectedSegmentName()).toEqual('Segment 1'); + + // vtk.js ignores the first pointer move after an idle period, and the + // sidebar work above is one, so each end is nudged onto and then pressed. + await nudgeTo(centerX - 40, centerY); + await pressAtPointer(); + await nudgeTo(centerX + 40, centerY); + await pressAtPointer(); + + await waitForNamedSegments(); + expect(await segmentNames()).toEqual(['Segment 1']); + + // The ruler is listed under the segment it named, with its length. + 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 setupTest(); + await volViewPage.activateRectangle(); + await clickAt(centerX - 60, centerY - 60); + await clickAt(centerX + 60, centerY + 60); + await volViewPage.selectTool('mdi-cursor-default'); + await waitForCircleCount( + axialView, + 2, + 'Placed rectangle should render both handles' + ); + 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('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'], + ['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 downloadFile(PROSTATEX_DATASET.url, PROSTATEX_DATASET.name); + 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 8bba5ac3b..3ecdba305 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 74724384d..bdc4e497d 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -48,11 +48,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/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/dicom-dimension-mismatch.e2e.ts b/tests/specs/dicom-dimension-mismatch.e2e.ts index 657ed0505..e0838dd62 100644 --- a/tests/specs/dicom-dimension-mismatch.e2e.ts +++ b/tests/specs/dicom-dimension-mismatch.e2e.ts @@ -5,11 +5,9 @@ // the series has to reach a usable state anyway. import * as path from 'path'; import * as fs from 'fs'; -import { cleanuptotal } from 'wdio-cleanuptotal-service'; import { volViewPage } from '../pageobjects/volview.page'; -import { TEMP_DIR } from '../../wdio.shared.conf'; import { buildSyntheticDicom, newUid } from './syntheticDicom'; -import { writeManifestToFile } from './utils'; +import { makeTempDir, writeManifestToFile } from './utils'; const IMAGE_ORIENTATION_PATIENT = [1, 0, 0, 0, 1, 0] as const; const SLICE_COUNT = 5; @@ -29,11 +27,7 @@ async function writeSeries( outlierSlice: number, manifestName: string ) { - const dir = path.join(TEMP_DIR, dirName); - fs.mkdirSync(dir, { recursive: true }); - cleanuptotal.addCleanup(async () => { - fs.rmSync(dir, { recursive: true, force: true }); - }); + const dir = makeTempDir(dirName); const studyUid = newUid(); const seriesUid = newUid(); diff --git a/tests/specs/dicom-modality-rescale.e2e.ts b/tests/specs/dicom-modality-rescale.e2e.ts index 9b7225ee2..fbc261b43 100644 --- a/tests/specs/dicom-modality-rescale.e2e.ts +++ b/tests/specs/dicom-modality-rescale.e2e.ts @@ -1,12 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import { cleanuptotal } from 'wdio-cleanuptotal-service'; -import { TEMP_DIR } from '../../wdio.shared.conf'; import { volViewPage } from '../pageobjects/volview.page'; import { buildSyntheticDicom, newUid } from './syntheticDicom'; import { waitForFirstCompleteCachedImageScalars } from './imageCacheUtils'; -import { writeManifestToFile } from './utils'; +import { makeTempDir, writeManifestToFile } from './utils'; const PUBLIC_DSC_SERIES_UID = '1.3.6.1.4.1.9590.100.1.2.284777661700890778225181143863199482857'; @@ -17,11 +15,7 @@ const COLUMNS = 4; async function writeRescaledSeries() { const dirName = `modality-rescale-${Date.now()}`; - const dir = path.join(TEMP_DIR, dirName); - fs.mkdirSync(dir, { recursive: true }); - cleanuptotal.addCleanup(async () => { - fs.rmSync(dir, { recursive: true, force: true }); - }); + const dir = makeTempDir(dirName); const studyUid = newUid(); const resources = STORED_VALUES.map((pixelValue, index) => { diff --git a/tests/specs/different-direction-labelmap.e2e.ts b/tests/specs/different-direction-labelmap.e2e.ts index 04c0f685a..f11cf61ae 100644 --- a/tests/specs/different-direction-labelmap.e2e.ts +++ b/tests/specs/different-direction-labelmap.e2e.ts @@ -9,13 +9,18 @@ import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; import * as path from 'path'; import * as fs from 'fs'; import { cleanuptotal } from 'wdio-cleanuptotal-service'; +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] + * SegmentMask group: [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. @@ -47,25 +52,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(DOWNLOAD_TIMEOUT); + await waitForSegmentContent('Right hip', DOWNLOAD_TIMEOUT); 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..472dce8f3 --- /dev/null +++ b/tests/specs/label-outline.e2e.ts @@ -0,0 +1,92 @@ +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), + { timeout: 30_000 } + ); + 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), + { timeout: 30_000 } + ); + 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), + { timeout: 30_000 } + ); + 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), + { timeout: 30_000 } + ); + 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), + { timeout: 30_000 } + ); + 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..7735b50be --- /dev/null +++ b/tests/specs/paint-eyedropper.e2e.ts @@ -0,0 +1,141 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from './configTestUtils'; +import { openUrls } from './utils'; +import { openAnnotationSegments } from './segmentationTestUtils'; + +const row = (name: string) => + $(`[data-testid="segment-list"] .item-row[aria-label="${name}"]`); +const selected = () => + $('[data-testid="segment-list"] .item-row[aria-current="true"]'); +const eyedropper = () => $('[data-testid="paint-eyedropper-button"]'); +const mouse = () => browser.action('pointer', { id: 'paint-mouse' }); +const keyboard = () => browser.action('key', { id: 'paint-keyboard' }); +const click = (x: number, y: number) => + mouse().move({ x, y }).down().up().perform(true); + +const expectBackgroundUnpainted = async (x: number, y: number) => { + await row('Segment 3').click(); + await eyedropper().click(); + await click(x, y); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 3'); +}; + +describe('Paint eyedropper', () => { + afterEach(async () => { + await browser.releaseActions(); + }); + + it('picks visible label maps without painting and restores the held mode', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.activatePaint(); + await openAnnotationSegments(); + await $('button.v-expansion-panel-title*=Paint').click(); + const canvas = (await volViewPage.getViews2D())[0].$('canvas'); + const location = await canvas.getLocation(); + const size = await canvas.getSize(); + const x = Math.round(location.x + size.width / 2); + const y = Math.round(location.y + size.height / 2); + + await click(x, y); + await expect(row('Segment 1')).toExist(); + await row('Segment 1').$('button[aria-label="Lock Segment 1"]').click(); + 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 e90da9feb..67b4ccb56 100644 --- a/tests/specs/paint-fill-holes.e2e.ts +++ b/tests/specs/paint-fill-holes.e2e.ts @@ -1,9 +1,19 @@ import AppPage from '../pageobjects/volview.page'; +import { PROSTATEX_DATASET } from './configTestUtils'; +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 AppPage.open(); - await AppPage.downloadProstateSample(); + await openUrls([PROSTATEX_DATASET]); await AppPage.waitForViews(); const views2D = await AppPage.getViews2D(); @@ -45,12 +55,7 @@ describe('Fill Holes paint process', () => { }); it('toggles the preview in place between processed and original', async () => { - await AppPage.processModeButton.waitForClickable(); - await AppPage.processModeButton.click(); - await AppPage.selectFillHolesProcess(); - - await AppPage.processPreviewButton.waitForClickable(); - await AppPage.processPreviewButton.click(); + await startFillHolesPreview(); // Previewing starts on the processed result. await AppPage.processProcessedButton.waitForDisplayed(); @@ -71,4 +76,24 @@ describe('Fill Holes paint process', () => { await AppPage.isPreviewToggleActive(AppPage.processProcessedButton) ).toBe(false); }); + + for (const preview of ['Original', 'Processed']) { + it(`cancels the ${preview} preview when its segment locks and allows a retry`, async () => { + await startFillHolesPreview(); + if (preview === 'Original') await AppPage.processOriginalButton.click(); + const lock = $('[data-testid="toggle-segments-locked-button"]'); + await lock.waitForClickable(); + await lock.click(); + await expect(AppPage.processPreviewButton).toBeDisplayed(); + await expect(AppPage.processApplyButton).not.toBeDisplayed(); + + await lock.click(); + await AppPage.processPreviewButton.waitForClickable(); + await AppPage.processPreviewButton.click(); + await AppPage.processApplyButton.waitForClickable(); + await AppPage.processApplyButton.click(); + await expect(AppPage.processPreviewButton).toBeDisplayed(); + await expect($('div*=Operation Failed')).not.toBeDisplayed(); + }); + } }); diff --git a/tests/specs/paint-tool-rendering.e2e.ts b/tests/specs/paint-tool-rendering.e2e.ts index 661af2fd8..717f07cb4 100644 --- a/tests/specs/paint-tool-rendering.e2e.ts +++ b/tests/specs/paint-tool-rendering.e2e.ts @@ -1,4 +1,5 @@ import AppPage from '../pageobjects/volview.page'; +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..d0cfc2093 --- /dev/null +++ b/tests/specs/polygon-rasterize-segment.e2e.ts @@ -0,0 +1,91 @@ +import AppPage from '../pageobjects/volview.page'; +import { + drawSquare, + nudgeTo, + rightPressAtPointer, + setupTest, +} from './annotationTestUtils'; +import { + addSegment, + openAnnotationSegments, + renameSegment, + segmentColor, + segmentNames, + waitForNamedSegments, +} from './segmentationTestUtils'; + +const RASTERIZE_ITEM = '.v-list-item-title=Rasterize'; + +// The menu comes off the widget's own pick, so hover the handle first and press +// without moving. +const openPolygonMenuAt = (x: number, y: number) => + browser.waitUntil( + async () => { + await nudgeTo(x, y); + await rightPressAtPointer(); + return $(RASTERIZE_ITEM).isDisplayed(); + }, + { + timeout: 15000, + interval: 500, + timeoutMsg: 'Right-clicking a polygon handle should open its menu', + } + ); + +describe('Polygon rasterize target', () => { + it('rasterizes into the segment the polygon was drawn with', async () => { + const { centerX, centerY } = await setupTest(); + const half = 60; + + // Paint a stroke first, so the image already holds a segment the polygon + // must not borrow. Activating the tool alone creates nothing. + await AppPage.activatePaint(); + const views2D = await AppPage.getViews2D(); + await AppPage.paintStrokeOnView(views2D[0]); + await AppPage.selectTool('mdi-pentagon-outline'); + + // Polygon draws with the entry selected in the Segments list, so the paint + // stroke's segment is already there and the new one is the second. + await openAnnotationSegments(); + await waitForNamedSegments(); + await addSegment(); + await renameSegment('Segment 2', 'Lesion'); + const lesionColor = await segmentColor('Lesion'); + + await drawSquare(centerX, centerY, half); + + expect(await segmentNames()).toEqual(['Segment 1', 'Lesion']); + + // The context menu belongs to a placed polygon, and the polygon tool keeps + // a placing one that would swallow the right click. + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(centerX + half, centerY - half); + await $(RASTERIZE_ITEM).click(); + + // Rasterizing lands in the polygon's own segment: it neither mints a + // second one nor borrows the segment the paint stroke made. + expect(await segmentNames()).toEqual(['Segment 1', 'Lesion']); + expect(await segmentColor('Lesion')).toEqual(lesionColor); + }); + + it('rasterizes a polygon drawn against an empty registry', async () => { + const { centerX, centerY } = await setupTest(); + const half = 60; + + // No paint stroke and no added entry, so the registry is empty and the + // polygon mints the segment it needs. Rasterize still has to work. + await AppPage.selectTool('mdi-pentagon-outline'); + await drawSquare(centerX, centerY, half); + + await AppPage.selectTool('mdi-cursor-default'); + await openPolygonMenuAt(centerX + half, centerY - half); + await $(RASTERIZE_ITEM).click(); + + await openAnnotationSegments(); + await waitForNamedSegments(); + await browser.waitUntil(async () => (await segmentNames()).length === 1, { + timeoutMsg: + 'Rasterizing against an empty registry should create one segment', + }); + }); +}); diff --git a/tests/specs/reveal-slice.e2e.ts b/tests/specs/reveal-slice.e2e.ts index 5134dbb91..5fd2b630f 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(); }; @@ -104,7 +97,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(); @@ -150,10 +143,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/sample-rendering.e2e.ts b/tests/specs/sample-rendering.e2e.ts index fc11c6d4d..19becbe08 100644 --- a/tests/specs/sample-rendering.e2e.ts +++ b/tests/specs/sample-rendering.e2e.ts @@ -1,3 +1,4 @@ +import { useCachedRemoteData } from '../cachedRemoteData'; import AppPage from '../pageobjects/volview.page'; const THRESHOLD = 12; // percent - handle pixel jitter in 3D view @@ -5,6 +6,7 @@ const RENDER_STABLE_TIMEOUT = 5000; describe('VolView', () => { it('should load and render a sample dataset', async () => { + await useCachedRemoteData(); await AppPage.open(); await AppPage.downloadProstateSample(); await AppPage.waitForViews(); diff --git a/tests/specs/seg-nrrd-export.e2e.ts b/tests/specs/seg-nrrd-export.e2e.ts index 684b7d4ab..68e0c9740 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 { ONE_CT_SLICE_DICOM, openConfigAndDataset } from './configTestUtils'; /** @@ -64,23 +64,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-group-download.e2e.ts b/tests/specs/segment-group-download.e2e.ts index 33f6b094e..8e3e8f02b 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 './configTestUtils'; +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,74 @@ 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. The name a download carries is the one typed into the save +// dialog, since there is no group name to type any more. +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: the panel +// is one flat list per image, so there is no group name to type any more. +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 f642e93b8..0111a0d6a 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 './configTestUtils'; +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..450745c57 --- /dev/null +++ b/tests/specs/segment-identity-stability.e2e.ts @@ -0,0 +1,109 @@ +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 './configTestUtils'; +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; +}; + +/** + * Splits a `.seg.nrrd` into its header fields and its decoded voxel bytes. + * VolView writes with compression, so the data section arrives gzipped. + */ +const readSegNrrd = (filePath: string) => { + const file = fs.readFileSync(filePath); + const raw = isGzip(file) ? zlib.gunzipSync(file) : file; + const split = raw.toString('latin1').indexOf('\n\n'); + const header = parseHeader(raw.toString('latin1', 0, split)); + + const data = raw.subarray(split + 2); + const voxels = isGzip(data) ? zlib.gunzipSync(data) : data; + return { header, voxels }; +}; + +const downloadSegmentGroup = async (stem: string) => { + const filePath = path.join(TEMP_DIR, `${stem}.seg.nrrd`); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + + await volViewPage.clickSaveSegmentsButton(); + const input = volViewPage.saveSegmentsFilenameInput; + await input.waitForDisplayed(); + await setValueVueInput(input, stem); + await volViewPage.saveSegmentsConfirmButton.click(); + + await waitForDownload(filePath, SAVE_TIMEOUT); + return readSegNrrd(filePath); +}; + +const editOnlySegment = async (name: string, red: number) => { + await $('[data-testid="segment-list"] button i[class~="mdi-pencil"]').click(); + const dialog = $('div[role="dialog"]'); + await dialog.waitForDisplayed(); + await setValueVueInput(dialog.$('.v-text-field input'), name); + await setValueVueInput(dialog.$('.v-color-picker-edit input'), String(red)); + await volViewPage.editLabelModalDoneButton.click(); + await dialog.waitForDisplayed({ reverse: true }); +}; + +describe('Segment identity under rename and recolor', function () { + this.timeout(120_000); + + it('leaves the exported voxels untouched while the name and color follow', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + + await volViewPage.activatePaint(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); + await openAnnotationSegments(); + await waitForNamedSegments(); + + const stamp = Date.now(); + const before = await downloadSegmentGroup(`identity-before-${stamp}`); + expect(before.header.get('Segment0_Name')).toEqual('Segment 1'); + expect(before.voxels.some((voxel) => voxel !== 0)).toBe(true); + + await editOnlySegment('Tumor', 0); + + const after = await downloadSegmentGroup(`identity-after-${stamp}`); + expect(after.header.get('Segment0_Name')).toEqual('Tumor'); + expect(after.header.get('Segment0_Color')).not.toEqual( + before.header.get('Segment0_Color') + ); + + // Same label value, same voxels: the edits moved identity, not geometry. + expect(after.header.get('Segment0_LabelValue')).toEqual( + before.header.get('Segment0_LabelValue') + ); + expect(after.voxels.equals(before.voxels)).toBe(true); + }); +}); diff --git a/tests/specs/segment-overlap.e2e.ts b/tests/specs/segment-overlap.e2e.ts new file mode 100644 index 000000000..a8c8300ef --- /dev/null +++ b/tests/specs/segment-overlap.e2e.ts @@ -0,0 +1,131 @@ +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 './configTestUtils'; +import { + addSegment, + lockSegment, + openAnnotationSegments, + waitForNamedSegments, +} from './segmentationTestUtils'; + +const SAVE_TIMEOUT = 40_000; + +const overlapNotice = () => $('[data-testid="save-overlap-notice"]'); + +const isGzip = (buf: Buffer) => buf[0] === 0x1f && buf[1] === 0x8b; + +/** + * A `.seg.nrrd`'s per-segment header fields and its decoded voxel bytes. + * 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 separator = line.indexOf(':='); + if (separator < 0) return; + header.set(line.slice(0, separator).trim(), line.slice(separator + 2)); + }); + + const data = raw.subarray(split + 2); + return { header, voxels: isGzip(data) ? zlib.gunzipSync(data) : data }; +}; + +/** Paints one stroke into a new segment, over the ground the last one covered. */ +const paintNewSegmentOverTheSameSpot = async () => { + await addSegment(); + const views2D = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views2D[0]); +}; + +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)); +}; + +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 paintNewSegmentOverTheSameSpot(); + await openSaveDialog(); + + await expect(overlapNotice()).not.toBeDisplayed(); + }); + + it('leaves a locked segment holding them, so the two overlap', async () => { + await lockSegment('Segment 1'); + await paintNewSegmentOverTheSameSpot(); + await openSaveDialog(); + + await expect(overlapNotice()).toBeDisplayed(); + }); + + it('saves overlapping segments losslessly, one file per layer', async () => { + await lockSegment('Segment 1'); + await paintNewSegmentOverTheSameSpot(); + await openSaveDialog(); + + const stem = `overlap-${Date.now()}`; + const zip = await saveAndUnzip(stem); + + expect(Object.keys(zip.files).sort()).toEqual([ + `${stem}.seg.nrrd`, + `${stem}_layer1.seg.nrrd`, + ]); + + // Each layer carries one of the two segments, and carries its voxels: the + // overlap costs a file, not a segment. + const layers = await Promise.all( + [`${stem}.seg.nrrd`, `${stem}_layer1.seg.nrrd`].map(async (name) => + readSegNrrd(Buffer.from(await zip.files[name].async('arraybuffer'))) + ) + ); + + expect(layers.map((layer) => layer.header.get('Segment0_Name'))).toEqual([ + 'Segment 1', + 'Segment 2', + ]); + layers.forEach((layer) => { + expect(layer.header.get('Segment1_Name')).toBeUndefined(); + expect(layer.voxels.some((voxel) => voxel !== 0)).toBe(true); + }); + }); +}); diff --git a/tests/specs/segment-preview-edits.e2e.ts b/tests/specs/segment-preview-edits.e2e.ts new file mode 100644 index 000000000..730a739bc --- /dev/null +++ b/tests/specs/segment-preview-edits.e2e.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +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(); +} + +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.processApplyButton.waitForClickable(); + await AppPage.processApplyButton.click(); + expect(await exportedVoxelCount('applied-preview')).toBe(filledCount); + }); + + it('cancels a preview before a brush stroke and preserves the new stroke', async () => { + await previewFill(); + await AppPage.selectTool('mdi-cursor-default'); + await AppPage.activatePaint(); + const views = await AppPage.getViews2D(); + await AppPage.paintStrokeOnView(views[0]); + await expect(AppPage.processApplyButton).not.toBeDisplayed(); + const count = await exportedVoxelCount('paint-after-preview'); + expect(count).toBeGreaterThan(originalCount); + expect(count).toBeLessThan(filledCount); + }); +}); diff --git a/tests/specs/segment-shared-type.e2e.ts b/tests/specs/segment-shared-type.e2e.ts new file mode 100644 index 000000000..fa523fe03 --- /dev/null +++ b/tests/specs/segment-shared-type.e2e.ts @@ -0,0 +1,102 @@ +import { MINIMAL_DICOM, ONE_CT_SLICE_DICOM } from './configTestUtils'; +import { openUrls } from './utils'; +import { volViewPage } from '../pageobjects/volview.page'; +import { + addSegment, + openAnnotationSegments, + renameSegment, + segmentColor, + segmentNames, + selectSegment, +} from './segmentationTestUtils'; + +const volumeCards = () => $$('.volume-card'); + +const activeCardIndex = () => + volumeCards().findIndex(async (card) => + ((await card.getAttribute('class')) ?? '').includes('volume-card-active') + ); + +// The module panel keeps every module mounted, so a volume card is only +// clickable while the Data module is the one on screen. +const showImage = async (index: number) => { + await $('button[data-testid="module-tab-Data"]').click(); + const cards = await volumeCards(); + await cards[index].scrollIntoView(); + await cards[index].click(); + await browser.waitUntil(async () => (await activeCardIndex()) === index, { + timeout: 30000, + 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, { + timeout: 30000, + timeoutMsg: 'Expected both volume cards to appear', + }); + + const first = await activeCardIndex(); + expect(first).toBeGreaterThanOrEqual(0); + const second = first === 0 ? 1 : 0; + + // A named type, painted on the first image. + await volViewPage.activatePaint(); + await paintOnViewedImage(); + await openAnnotationSegments(); + await expectSegments(['Segment 1']); + await addSegment(); + await renameSegment('Segment 2', 'Tumor'); + await selectSegment('Tumor'); + const tumorColor = await segmentColor('Tumor'); + + // The registry is image-independent, so viewing the other image offers + // exactly the same types, and creates nothing. + await showImage(second); + await expectSegments(['Segment 1', 'Tumor']); + expect(await volViewPage.getNotificationsCount()).toEqual(0); + + // Painting there writes into this image's own mask for the same type. + await paintOnViewedImage(); + await expectSegments(['Segment 1', 'Tumor']); + expect(await segmentColor('Tumor')).toEqual(tumorColor); + + // Renaming the type renames it everywhere, because it is one type. + await renameSegment('Tumor', 'Tumor A'); + await expectSegments(['Segment 1', 'Tumor A']); + + await showImage(first); + await expectSegments(['Segment 1', 'Tumor A']); + await paintOnViewedImage(); + await expectSegments(['Segment 1', 'Tumor A']); + }); +}); diff --git a/tests/specs/segment-shortcuts.e2e.ts b/tests/specs/segment-shortcuts.e2e.ts new file mode 100644 index 000000000..f60600acb --- /dev/null +++ b/tests/specs/segment-shortcuts.e2e.ts @@ -0,0 +1,138 @@ +import * as path from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from './configTestUtils'; +import { openUrls, waitForFileExists, SESSION_SAVE_TIMEOUT } from './utils'; +import { openAnnotationSegments } from './segmentationTestUtils'; + +const selected = () => + $('[data-testid="segment-list"] .item-row[aria-current="true"]'); +const segmentRow = (name: string) => + $(`[data-testid="segment-list"] .item-row[aria-label="${name}"]`); +const titles = () => + browser.execute(() => + Array.from( + document.querySelectorAll('[data-testid="segment-list"] .item-row'), + (row) => row.getAttribute('aria-label') + ) + ); + +// Native drag events exercise the same handle/drop handlers without depending +// on a browser's drag-distance threshold or autoscroll timing. +const dragBefore = async (name: string, target: string) => { + const row = await segmentRow(name); + const destination = await segmentRow(target); + const handle = await row.$('.reorder-handle'); + await browser.execute( + (source, to) => { + const dataTransfer = new DataTransfer(); + const bounds = to.getBoundingClientRect(); + source.dispatchEvent( + new DragEvent('dragstart', { bubbles: true, dataTransfer }) + ); + to.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer, + clientY: bounds.top + 1, + }) + ); + }, + handle, + destination + ); + await expect(destination).toHaveAttribute('data-drop-position', 'before'); + expect( + await browser.execute( + (element) => getComputedStyle(element).borderTopColor, + destination + ) + ).not.toBe('rgba(0, 0, 0, 0)'); + await browser.execute( + (source, to) => { + const dataTransfer = new DataTransfer(); + to.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + clientY: to.getBoundingClientRect().top + 1, + }) + ); + source.dispatchEvent( + new DragEvent('dragend', { bubbles: true, dataTransfer }) + ); + }, + handle, + destination + ); +}; + +describe('Segment shortcuts and ordering', () => { + it('selects the first ten rows, follows reordered rows keeps typed digits in fields and restores saved order', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await openAnnotationSegments(); + for (let i = 0; i < 11; i++) { + await $('[data-testid="segment-list"] .create-row').click(); + await expect(segmentRow(`Segment ${i + 1}`)).toExist(); + } + + for (let i = 1; i <= 10; i++) { + await browser.keys(String(i % 10)); + await expect(selected()).toHaveAttribute('aria-label', `Segment ${i}`); + await expect((await segmentRow(`Segment ${i}`)).$('kbd')).toHaveText( + String(i % 10) + ); + } + await expect((await segmentRow('Segment 11')).$('kbd')).not.toExist(); + + await dragBefore('Segment 11', 'Segment 1'); + await browser.waitUntil(async () => (await titles())[0] === 'Segment 11'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 10'); + await browser.keys('1'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 11'); + await browser.keys('0'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 9'); + await expect((await segmentRow('Segment 10')).$('kbd')).not.toExist(); + + const firstHandle = (await segmentRow('Segment 11')).$('.reorder-handle'); + await firstHandle.click(); + await browser.keys(['Alt', 'ArrowDown']); + await browser.waitUntil(async () => (await titles())[1] === 'Segment 11'); + await browser.keys('1'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + + await (await segmentRow('Segment 1')) + .$('[data-testid="edit-segment-button"]') + .click(); + const input = $('div[role="dialog"] .v-text-field input'); + await input.click(); + await input.addValue('1234567890'); + await expect(input).toHaveValue('Segment 11234567890'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 1'); + await volViewPage.editLabelModalDoneButton.click(); + await expect(selected()).toHaveAttribute( + 'aria-label', + 'Segment 11234567890' + ); + const order = await titles(); + const session = await volViewPage.saveSession(); + await waitForFileExists(path.join(TEMP_DIR, session), SESSION_SAVE_TIMEOUT); + await volViewPage.open(`?urls=[tmp/${session}]`); + await volViewPage.waitForViews(); + await openAnnotationSegments(); + await browser.waitUntil( + async () => (await titles()).length === order.length + ); + expect(await titles()).toEqual(order); + await expect(selected()).toHaveAttribute( + 'aria-label', + 'Segment 11234567890' + ); + await browser.keys('2'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 11'); + await browser.keys('0'); + await expect(selected()).toHaveAttribute('aria-label', 'Segment 9'); + }); +}); diff --git a/tests/specs/segment-tooltips.e2e.ts b/tests/specs/segment-tooltips.e2e.ts new file mode 100644 index 000000000..236bee7e1 --- /dev/null +++ b/tests/specs/segment-tooltips.e2e.ts @@ -0,0 +1,80 @@ +import type { ChainablePromiseElement } from 'webdriverio'; +import { volViewPage } from '../pageobjects/volview.page'; +import { ONE_CT_SLICE_DICOM } from './configTestUtils'; +import { moveTo } from './annotationTestUtils'; +import { + openAnnotationSegments, + renameSegment, + segmentRow, + waitForSegmentContent, +} from './segmentationTestUtils'; +import { openUrls } from './utils'; + +const descriptionOf = async (element: ChainablePromiseElement) => { + const id = await element.getAttribute('aria-describedby'); + expect(id).toBeTruthy(); + return $(`[id="${id}"]`); +}; + +describe('Segment tooltips', () => { + it('shows row descriptions on hover and explains locked controls on keyboard focus', async () => { + await openUrls([ONE_CT_SLICE_DICOM]); + await volViewPage.activatePaint(); + const views = await volViewPage.getViews2D(); + await volViewPage.paintStrokeOnView(views[0]); + await openAnnotationSegments(); + await waitForSegmentContent('Segment 1'); + + const name = 'A segment name that is longer than the available row width'; + await renameSegment('Segment 1', name); + const row = await segmentRow(name); + const title = await row.$('.v-list-item-title'); + await title.moveTo(); + const titleTooltip = await descriptionOf(title); + await expect(titleTooltip).toBeDisplayed(); + await expect(titleTooltip).toHaveText(name); + await moveTo(10, 10); + await expect(titleTooltip).not.toBeDisplayed(); + + const lock = await row.$('button[aria-label^="Lock "]'); + await lock.moveTo(); + const lockTooltip = await descriptionOf(lock); + await expect(lockTooltip).toBeDisplayed(); + await expect(lockTooltip).toHaveText( + 'Lock. Painting over this segment shares its voxels instead of taking them.' + ); + await lock.click(); + await moveTo(10, 10); + + await browser.keys(['Shift', 'Tab']); + const edit = await row.$('[data-testid="edit-segment-button"]'); + const editActivator = await edit.$('..'); + await expect(edit).toBeDisabled(); + expect( + await browser.execute( + (element) => document.activeElement === element, + await editActivator + ) + ).toBe(true); + const editTooltip = await descriptionOf(editActivator); + await expect(editTooltip).toBeDisplayed(); + await expect(editTooltip).toHaveText('Unlock this segment to edit it'); + + await browser.keys(['Shift', 'Tab']); + await browser.keys(['Shift', 'Tab']); + const color = await row.$('[data-testid="segment-color-button"]'); + const colorActivator = await color.$('..'); + await expect(color).toBeDisabled(); + expect( + await browser.execute( + (element) => document.activeElement === element, + await colorActivator + ) + ).toBe(true); + const colorTooltip = await descriptionOf(colorActivator); + await expect(colorTooltip).toBeDisplayed(); + await expect(colorTooltip).toHaveText( + 'Unlock this segment to change its color' + ); + }); +}); diff --git a/tests/specs/segmentation-extension.e2e.ts b/tests/specs/segmentation-extension.e2e.ts new file mode 100644 index 000000000..b9af25607 --- /dev/null +++ b/tests/specs/segmentation-extension.e2e.ts @@ -0,0 +1,90 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { writeManifestToFile } from './utils'; +import { + openAnnotationSegments, + waitForSegmentContent, +} from './segmentationTestUtils'; + +const writeImages = () => { + 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, 'extension-case.nrrd'), + `${header.join('\n')}\n\n${pixels}` + ); + writeFileSync( + join(TEMP_DIR, 'extension-case.seg.nrrd'), + `${header.join('\n')}\nSegment0_LabelValue:=1\nSegment0_Name:=Matched mask\n\n${pixels}` + ); +}; + +const openWithIo = async (io: Record) => { + writeImages(); + 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..fcfcfbad2 --- /dev/null +++ b/tests/specs/segmentationTestUtils.ts @@ -0,0 +1,158 @@ +import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; + +const SEGMENT_LIST = '[data-testid="segment-list"]'; +const SHAPE_ROW = '[data-testid="segment-shape-row"]'; + +// Only a row standing for a real item carries a title; the trailing "create" +// row does not. +const namesIn = (root: string) => + $$(`${root} .item-row .v-list-item-title`).map((title) => title.getText()); + +const rowNamed = async (root: string, name: string) => { + const rows = await $$(`${root} .item-row`); + for (const row of rows) { + const title = await row.$('.v-list-item-title'); + if ((await title.isExisting()) && (await title.getText()) === name) { + return row; + } + } + throw new Error(`No row named "${name}" under ${root}`); +}; + +const dotColor = async (root: string, name: string) => { + const row = await rowNamed(root, name); + const dot = await row.$('.color-dot'); + return (await dot.getCSSProperty('background-color')).value; +}; + +const addRow = async (root: string) => { + const before = await namesIn(root); + await $(`${root} .create-row`).click(); + await browser.waitUntil( + async () => (await namesIn(root)).length === before.length + 1, + { timeoutMsg: `Expected the create row to add an item under ${root}` } + ); +}; + +const editDialog = () => $('div[role="dialog"]'); + +const renameInOpenDialog = async (to: string) => { + const dialog = editDialog(); + await dialog.waitForDisplayed(); + // Name is the first text field in the editor both lists open. + await setValueVueInput(dialog.$('.v-text-field input'), to); + await volViewPage.editLabelModalDoneButton.click(); + await dialog.waitForDisplayed({ reverse: true }); +}; + +export const segmentNames = () => namesIn(SEGMENT_LIST); + +export const segmentRow = (name: string) => rowNamed(SEGMENT_LIST, name); + +/** Waits for mask or shape content, which may arrive after the catalog name. */ +export const waitForSegmentContent = async (name: string, timeout?: number) => { + const row = await segmentRow(name); + await row + .$('button[data-testid="reveal-segment-button"]') + .waitForEnabled(timeout ? { timeout } : undefined); +}; + +export const segmentColor = (name: string) => dotColor(SEGMENT_LIST, name); + +export const addSegment = () => addRow(SEGMENT_LIST); + +export const selectSegment = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + // Clicking the title rather than the row keeps the hit away from the + // right-aligned controls on a narrow sidebar. + await row.$('.v-list-item-title').click(); +}; + +/** The row the list marks active, which is what a tool draws with. */ +export const selectedSegmentName = () => + $( + `${SEGMENT_LIST} .item-row.v-list-item--active .v-list-item-title` + ).getText(); + +/** Page-coordinate top of the Segments list, for asserting it has not moved. */ +export const segmentListTop = async () => + (await $(SEGMENT_LIST).getLocation()).y; + +const deleteIn = async (root: string, name: string) => { + const row = await rowNamed(root, name); + await row.$('button i[class~="mdi-delete"]').click(); + await browser.waitUntil(async () => !(await namesIn(root)).includes(name), { + timeoutMsg: `Expected "${name}" to leave ${root}`, + }); +}; + +export const deleteSegment = (name: string) => deleteIn(SEGMENT_LIST, name); + +/** Puts the 2D views on the middle of what this image stores for a segment. */ +export const revealSegment = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + const button = await row.$('button[data-testid="reveal-segment-button"]'); + await button.waitForClickable(); + await button.click(); +}; + +const renameIn = async (root: string, from: string, to: string) => { + const row = await rowNamed(root, from); + await row.$('button[data-testid="edit-segment-button"]').click(); + await renameInOpenDialog(to); + await browser.waitUntil(async () => (await namesIn(root)).includes(to), { + timeoutMsg: `Expected ${root} to show "${to}"`, + }); +}; + +export const renameSegment = (from: string, to: string) => + renameIn(SEGMENT_LIST, from, to); + +/** The Segments list is pinned at the top of the Annotations panel. */ +export const openAnnotationSegments = async () => { + await volViewPage.annotationsModuleTab.click(); + await $(SEGMENT_LIST).waitForDisplayed(); +}; + +/** Opens the flat list of rulers, rectangles and polygons on the viewed image. */ +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()); + +/** Waits for the viewed image to render at least one named segment. */ +export const waitForNamedSegments = async (timeout?: number) => { + await $(SEGMENT_LIST).waitForDisplayed(timeout ? { timeout } : undefined); + // 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); + }, + { + ...(timeout ? { timeout } : {}), + timeoutMsg: 'Expected the viewed image to have a named segment', + } + ); +}; + +/** + * Locks a segment, which is the whole opt-in for overlap: a locked segment + * keeps the voxels a later stroke paints over it. + */ +export const lockSegment = async (name: string) => { + const row = await rowNamed(SEGMENT_LIST, name); + await row.$('button i[class~="mdi-lock-open"]').click(); + await row.$('button i[class~="mdi-lock"]').waitForExist({ + timeoutMsg: `Expected "${name}" to show as locked`, + }); +}; 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 42c5a84df..1a2582699 100644 --- a/tests/specs/session-large-uri-base.e2e.ts +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -5,6 +5,7 @@ import JSZip from 'jszip'; import { cleanuptotal } from 'wdio-cleanuptotal-service'; import { volViewPage } from '../pageobjects/volview.page'; import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; +import { openAnnotationSegments, segmentNames } from './segmentationTestUtils'; const writeBufferToFile = async (data: Buffer, fileName: string) => { const filePath = path.join(TEMP_DIR, fileName); @@ -20,7 +21,8 @@ const createNiftiGz = ( dimY: number, dimZ: number, datatype: number, - bitpix: number + bitpix: number, + foreground = false ) => { const bytesPerVoxel = bitpix / 8; const header = Buffer.alloc(352); @@ -57,6 +59,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 }); }; @@ -75,6 +80,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', @@ -110,7 +129,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 @@ -137,7 +156,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); @@ -154,32 +173,20 @@ describe('Session with large URI base and nii.gz labelmap', function () { await volViewPage.open(`?urls=[tmp/${sessionFileName}]`); 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, @@ -188,6 +195,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 c41faea8b..951311d8a 100644 --- a/tests/specs/session-state-lifecycle.e2e.ts +++ b/tests/specs/session-state-lifecycle.e2e.ts @@ -1,15 +1,33 @@ import * as path from 'path'; import * as fs from 'fs'; import JSZip from 'jszip'; -import { MINIMAL_501_SESSION, PROSTATEX_DATASET } from './configTestUtils'; +import { + MINIMAL_501_SESSION, + PROSTATEX_DATASET, + PROSTATE_610_LABELMAP_MANIFEST, + PROSTATE_SEGMENT_GROUP, +} from './configTestUtils'; import { downloadFile, - openUrls, + openVolViewPage, SESSION_SAVE_TIMEOUT, waitForFileExists, + writeManifestToFile, } from './utils'; -import { setValueVueInput, volViewPage } from '../pageobjects/volview.page'; +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 () => { @@ -62,6 +80,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(); @@ -69,45 +94,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(); @@ -119,32 +144,86 @@ 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]); + it('sanitizes stored labelmap names when saving them into the session zip', async () => { + await downloadFile(PROSTATEX_DATASET.url, PROSTATEX_DATASET.name); + await downloadFile(PROSTATE_SEGMENT_GROUP.url, PROSTATE_SEGMENT_GROUP.name); - const segmentGroupName = 'Liver: left/right*?'; + // 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 () => { + await downloadFile(PROSTATEX_DATASET.url, PROSTATEX_DATASET.name); + await downloadFile(PROSTATE_SEGMENT_GROUP.url, PROSTATE_SEGMENT_GROUP.name); + + 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 5d712f6b1..cb7e1bc1e 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 './configTestUtils'; import { downloadFile, openVolViewPage, writeManifestToZip } from './utils'; +import { openSegmentShapes } from './segmentationTestUtils'; describe('Sparse manifest with prostate rectangle', () => { it('loads prostate dataset with lesion rectangle annotation', async () => { @@ -54,19 +55,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 e160cae5e..0cba20887 100644 --- a/tests/specs/sparse-manifest.e2e.ts +++ b/tests/specs/sparse-manifest.e2e.ts @@ -11,6 +11,12 @@ import { 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 () => { @@ -73,19 +79,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; @@ -124,26 +123,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(DOWNLOAD_TIMEOUT); + await waitForSegmentContent('Right hip', DOWNLOAD_TIMEOUT); // 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/state-manifest.e2e.ts b/tests/specs/state-manifest.e2e.ts index b57ecef11..5ab0048fc 100644 --- a/tests/specs/state-manifest.e2e.ts +++ b/tests/specs/state-manifest.e2e.ts @@ -1,3 +1,4 @@ +import { useCachedRemoteData } from '../cachedRemoteData'; import * as path from 'path'; import { FIXTURES, applyTestViewport } from '../../wdio.shared.conf'; import { volViewPage } from '../pageobjects/volview.page'; @@ -5,6 +6,7 @@ import { openVolViewPage, writeManifestToZip } from './utils'; describe('State file manifest.json code', () => { it('has no errors loading version 5.0.1 manifest.json file ', async () => { + await useCachedRemoteData(); const manifestPath = path.join( FIXTURES, 'pre-multi-4up.5-0-1.volview.json' @@ -17,6 +19,7 @@ describe('State file manifest.json code', () => { it('loads 5.0.1 manifest with axial layer layout', async () => { await browser.reloadSession(); await applyTestViewport(browser); + await useCachedRemoteData(); const manifestPath = path.join(FIXTURES, 'layer-axial.5-0-1.volview.json'); const fileName = 'temp-layer-axial.volview.zip'; await writeManifestToZip(manifestPath, fileName); diff --git a/tests/specs/ultrasound-spacing.e2e.ts b/tests/specs/ultrasound-spacing.e2e.ts index 51d7a8677..0b47563bc 100644 --- a/tests/specs/ultrasound-spacing.e2e.ts +++ b/tests/specs/ultrasound-spacing.e2e.ts @@ -1,6 +1,7 @@ import { US_MULTIFRAME_DICOM } from './configTestUtils'; 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 531804bbf..8b02f9da6 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -41,6 +41,19 @@ export const downloadFile = async (url: string, fileName: string) => { return linkCachedDataset(fileName); }; +/** + * A directory under TEMP_DIR for one spec's generated files, removed with + * everything in it once the run finishes. + */ +export function makeTempDir(dirName: string) { + const dir = path.join(TEMP_DIR, dirName); + fs.mkdirSync(dir, { recursive: true }); + cleanuptotal.addCleanup(async () => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + export async function writeManifestToFile(manifest: unknown, fileName: string) { const filePath = path.join(TEMP_DIR, fileName); await fs.promises.writeFile(filePath, JSON.stringify(manifest)); @@ -113,6 +126,28 @@ export const waitForFileExists = (filePath: string, timeout: number) => }); }); +/** + * Waits for a download to land and to finish being written. The file appears + * empty first, so its existence alone is not enough to read it. + */ +export const waitForDownload = async (filePath: string, timeout: number) => { + await waitForFileExists(filePath, timeout); + await browser.waitUntil( + () => { + try { + return fs.statSync(filePath).size > 0; + } catch { + return false; + } + }, + { + timeout: 10_000, + interval: 500, + timeoutMsg: `${path.basename(filePath)} stayed 0 bytes`, + } + ); +}; + export async function openUrls(urlsAndNames: Array) { await Promise.all( urlsAndNames.map((resource) => downloadFile(resource.url, resource.name)) diff --git a/wdio.shared.conf.ts b/wdio.shared.conf.ts index 1f6495cf4..e7833eacf 100644 --- a/wdio.shared.conf.ts +++ b/wdio.shared.conf.ts @@ -6,7 +6,7 @@ import { SevereServiceError } from 'webdriverio'; import { projectRoot } from './tests/e2eTestUtils'; import { AUX_PORT, BASE_URL, TEST_PORT } from './tests/e2ePorts'; -const TEST_DATASETS = [ +export const TEST_DATASETS = [ { url: 'https://data.kitware.com/api/v1/file/6566aa81c5a2b36857ad1783/download', name: 'CT000085.dcm',