From a581368e375ca1c7d02d787e358cc0d2db8ba6f6 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 31 Aug 2026 20:56:55 -0400 Subject: [PATCH 001/221] chore(segmentation): baseline for segment model work Baseline on branch segment-model, cut from main at f482c6d0. Unit suite: 97 passed, 1 skipped file (955 tests: 946 passed, 9 skipped); the skipped file is the pre-existing src/core/thumbnailers/__tests__/vtk-image.spec.ts. Lint and typecheck: pass. Inventory of useSegmentGroupStore|activeSegmentGroupID|activeSegment over src (excluding __tests__): 27 files, 111 sites. Heaviest files: 32 src/store/tools/paint.ts 14 src/store/tools/paintProcess.ts 5 src/store/tools/fillHoles.ts 4 src/processing/applyResults.ts 4 src/io/state-file/serialize.ts 3 src/utils/bugReport.ts 3 src/store/segmentGroups.ts 3 src/processing/engine/mintLabelmap.ts 3 src/processing/composables/useInputStaging.ts 3 src/processing/components/JobsModule.vue 3 src/components/tools/polygon/PolygonTool.vue 3 src/components/tools/paint/PaintWidget2D.vue 3 src/components/SegmentList.vue 3 src/components/SegmentGroupControls.vue From 0536d7394336164cf026152bad62c8823b9bca5f Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 31 Aug 2026 21:09:40 -0400 Subject: [PATCH 002/221] feat(segmentation): add segment model types and helpers --- src/types/__tests__/segmentation.spec.ts | 180 +++++++++++++++++++++++ src/types/segmentation.ts | 100 +++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/types/__tests__/segmentation.spec.ts create mode 100644 src/types/segmentation.ts diff --git a/src/types/__tests__/segmentation.spec.ts b/src/types/__tests__/segmentation.spec.ts new file mode 100644 index 000000000..11fa2ad21 --- /dev/null +++ b/src/types/__tests__/segmentation.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; +import type { RGBAColor } from '@kitware/vtk.js/types'; + +import { TOOL_COLORS } from '@/src/config'; +import { + cssColorToRGBA, + emptyExtent, + isEmptyExtent, + rgbaToCssColor, +} from '@/src/types/segmentation'; +import type { + ActiveSegmentIntent, + ActiveSegmentationTarget, + Extent3D, + LabelmapBinding, + Segment, + Segmentation, +} from '@/src/types/segmentation'; + +describe('emptyExtent', () => { + it('is the pinned empty sentinel', () => { + expect(emptyExtent()).toEqual([0, -1, 0, -1, 0, -1]); + }); + + it('returns a fresh extent per call', () => { + const first = emptyExtent(); + first[1] = 10; + + expect(emptyExtent()).toEqual([0, -1, 0, -1, 0, -1]); + }); +}); + +describe('isEmptyExtent', () => { + it('accepts the empty sentinel', () => { + expect(isEmptyExtent(emptyExtent())).toBe(true); + }); + + it('rejects an extent covering a whole image', () => { + expect(isEmptyExtent([0, 9, 0, 19, 0, 29])).toBe(false); + }); + + it('rejects a single voxel extent', () => { + // vtk.js extents are inclusive, so min === max is one voxel, not empty. + expect(isEmptyExtent([4, 4, 5, 5, 6, 6])).toBe(false); + }); + + it.each([ + ['i', [5, 4, 0, 9, 0, 9] as Extent3D], + ['j', [0, 9, 5, 4, 0, 9] as Extent3D], + ['k', [0, 9, 0, 9, 5, 4] as Extent3D], + ])('is empty when the %s axis is inverted', (_axis, extent) => { + expect(isEmptyExtent(extent)).toBe(true); + }); +}); + +describe('cssColorToRGBA', () => { + it('parses a tool color hex string', () => { + expect(cssColorToRGBA('#58f24c')).toEqual([88, 242, 76, 255]); + }); + + it('parses an alpha channel when present', () => { + expect(cssColorToRGBA('#58f24c80')).toEqual([88, 242, 76, 128]); + }); + + it('parses the named color used by the vector tool label defaults', () => { + expect(cssColorToRGBA('red')).toEqual([255, 0, 0, 255]); + }); +}); + +describe('rgbaToCssColor', () => { + it.each([ + [[88, 242, 76, 255] as RGBAColor], + [[0, 0, 0, 255] as RGBAColor], + [[214, 0, 0, 128] as RGBAColor], + ])('round trips %j', (rgba) => { + expect(cssColorToRGBA(rgbaToCssColor(rgba))).toEqual(rgba); + }); + + it('emits a css color literal', () => { + expect(rgbaToCssColor([88, 242, 76, 255])).toMatch( + /^(#[0-9a-fA-F]{6,8}|rgba?\(.+\))$/ + ); + }); +}); + +describe('color conversion of existing label colors', () => { + it.each(TOOL_COLORS)('round trips %s', (css) => { + const rgba = cssColorToRGBA(css); + + expect(cssColorToRGBA(rgbaToCssColor(rgba))).toEqual(rgba); + }); +}); + +describe('segment model', () => { + it('holds no labelmap binding until voxels are allocated', () => { + const segment: Segment = { + id: 'segment-1', + name: 'Tumor', + color: [255, 0, 0, 255], + visible: true, + locked: false, + representations: {}, + }; + + expect(segment.representations.labelmap).toBeUndefined(); + }); + + it('binds a segment to one label value inside one artifact', () => { + const binding: LabelmapBinding = { + artifactId: 'artifact-1', + labelValue: 3, + extent: [0, 9, 0, 19, 0, 29], + }; + const segment: Segment = { + id: 'segment-1', + name: 'Tumor', + color: [255, 0, 0, 255], + visible: true, + locked: false, + representations: { labelmap: binding }, + }; + + expect(segment.representations.labelmap).toEqual({ + artifactId: 'artifact-1', + labelValue: 3, + extent: [0, 9, 0, 19, 0, 29], + }); + expect(isEmptyExtent(binding.extent)).toBe(false); + }); + + it('keeps segment order separate from the segment records', () => { + const makeSegment = (id: string, name: string): Segment => ({ + id, + name, + color: [0, 0, 0, 255], + visible: true, + locked: false, + representations: {}, + }); + const segmentation: Segmentation = { + id: 'segmentation-1', + name: 'Segmentation', + parentImageId: 'image-1', + segments: { + 'segment-1': makeSegment('segment-1', 'Tumor'), + 'segment-2': makeSegment('segment-2', 'Tumor'), + }, + order: ['segment-2', 'segment-1'], + }; + + expect(segmentation.order).toEqual(['segment-2', 'segment-1']); + expect(Object.keys(segmentation.segments).sort()).toEqual([ + 'segment-1', + 'segment-2', + ]); + }); +}); + +describe('active segment intent', () => { + it('targets one segment per image', () => { + const target: ActiveSegmentationTarget = { + segmentationId: 'segmentation-1', + segmentId: 'segment-1', + }; + const intent: ActiveSegmentIntent = { + name: 'Tumor', + color: [255, 0, 0, 255], + targetByImageId: { + 'image-1': target, + 'image-2': { + segmentationId: 'segmentation-2', + segmentId: 'segment-7', + }, + }, + }; + + expect(intent.targetByImageId['image-1']).toEqual(target); + expect(intent.targetByImageId['image-2'].segmentId).toBe('segment-7'); + }); +}); diff --git a/src/types/segmentation.ts b/src/types/segmentation.ts new file mode 100644 index 000000000..284b1c4ef --- /dev/null +++ b/src/types/segmentation.ts @@ -0,0 +1,100 @@ +import type { RGBAColor } from '@kitware/vtk.js/types'; + +import { hexaToRGBA, rgbaToHexa } from '@/src/utils/color'; + +/** vtk.js index-space extent order: [iMin, iMax, jMin, jMax, kMin, kMax]. */ +export type Extent3D = [number, number, number, number, number, number]; + +export type LabelmapBinding = { + artifactId: string; + labelValue: number; + extent: Extent3D; // parent image index space; full parent extent in this phase +}; + +export type Segment = { + id: string; + name: string; + color: RGBAColor; + visible: boolean; + locked: boolean; + representations: { + // absent until voxels are allocated + labelmap?: LabelmapBinding; + }; +}; + +export type Segmentation = { + id: string; + name: string; + parentImageId: string; + segments: Record; + order: string[]; +}; + +export type ActiveSegmentationTarget = { + segmentationId: string; + segmentId: string; +}; + +export type ActiveSegmentIntent = { + name: string; + color: RGBAColor; + targetByImageId: Record; +}; + +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] + ); +} + +// CSS basic color keywords; the vector tool label defaults in src/config.ts use 'red'. +const NAMED_COLORS: Record = { + black: '000000', + silver: 'c0c0c0', + gray: '808080', + white: 'ffffff', + maroon: '800000', + red: 'ff0000', + purple: '800080', + fuchsia: 'ff00ff', + green: '008000', + lime: '00ff00', + olive: '808000', + yellow: 'ffff00', + navy: '000080', + blue: '0000ff', + teal: '008080', + aqua: '00ffff', +}; + +const HEX_COLOR = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/; + +const expandShorthandHex = (hex: string) => + hex.length <= 4 + ? hex + .split('') + .map((digit) => digit.repeat(2)) + .join('') + : hex; + +/** + * Parses the CSS color strings label colors are stored as (hex, with or + * without an alpha channel, plus the basic color keywords). Unparseable input + * falls back to opaque black so migrating a state file cannot throw. + */ +export function cssColorToRGBA(css: string): RGBAColor { + const value = css.trim().toLowerCase(); + const hex = NAMED_COLORS[value] ?? HEX_COLOR.exec(value)?.[1]; + if (!hex) return [0, 0, 0, 255]; + return hexaToRGBA(expandShorthandHex(hex)); +} + +export function rgbaToCssColor(rgba: RGBAColor) { + return rgbaToHexa(rgba); +} From 208a3a477cd9299a8e1a5e0923e744b2b1ce7d3b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 31 Aug 2026 21:31:13 -0400 Subject: [PATCH 003/221] feat(segmentation): add segmentation store --- src/store/__tests__/segmentations.spec.ts | 476 ++++++++++++++++++++++ src/store/segmentations.ts | 270 ++++++++++++ 2 files changed, 746 insertions(+) create mode 100644 src/store/__tests__/segmentations.spec.ts create mode 100644 src/store/segmentations.ts diff --git a/src/store/__tests__/segmentations.spec.ts b/src/store/__tests__/segmentations.spec.ts new file mode 100644 index 000000000..a4f9531c2 --- /dev/null +++ b/src/store/__tests__/segmentations.spec.ts @@ -0,0 +1,476 @@ +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 { CATEGORICAL_COLORS } from '@/src/config'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/store/segmentations'; + +const DIMENSIONS = [4, 4, 2] as const; +const VOXEL_COUNT = DIMENSIONS[0] * DIMENSIONS[1] * DIMENSIONS[2]; + +async function seatImage(id: string, name = 'CT') { + 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(VOXEL_COUNT), + }) + ); + image.computeTransforms(); + useImageCacheStore().addVTKImageData(image, name, { id }); + await nextTick(); + return id; +} + +const store = () => useSegmentationStore(); + +const artifactScalars = (artifactId: string) => + store().artifactIndex[artifactId].getPointData().getScalars().getData(); + +const bindingOf = (segmentationId: string, segmentId: string) => + store().getSegment(segmentationId, segmentId).representations.labelmap; + +/** Creates a bound segment and hands back the ids and its binding. */ +function makeBoundSegment(segmentationId: string, name?: string) { + const segment = store().createSegment( + segmentationId, + name ? { name } : undefined + ); + store().ensureLabelmapBinding(segmentationId, segment.id); + const binding = bindingOf(segmentationId, segment.id)!; + return { id: segment.id, binding }; +} + +describe('segmentation store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + describe('one segmentation per parent image', () => { + it('reports no segmentation for an image until one is ensured', async () => { + await seatImage('img-1'); + + expect(store().getSegmentationForImage('img-1')).toBeFalsy(); + }); + + it('ensures a segmentation bound to its parent image', async () => { + await seatImage('img-1'); + + const segmentation = store().ensureSegmentationForImage('img-1'); + + expect(segmentation.parentImageId).toBe('img-1'); + expect(segmentation.segments).toEqual({}); + expect(segmentation.order).toEqual([]); + expect(store().byParentImage['img-1']).toBe(segmentation.id); + expect(store().segmentations[segmentation.id]).toBeTruthy(); + }); + + it('returns the existing segmentation on a second ensure', async () => { + await seatImage('img-1'); + + const first = store().ensureSegmentationForImage('img-1'); + store().createSegment(first.id); + const second = store().ensureSegmentationForImage('img-1'); + + expect(second.id).toBe(first.id); + expect(Object.keys(store().segmentations)).toHaveLength(1); + expect(store().getSegmentationForImage('img-1')!.id).toBe(first.id); + }); + + it('keeps separate segmentations for separate images', async () => { + await seatImage('img-1'); + await seatImage('img-2', 'PET'); + + const first = store().ensureSegmentationForImage('img-1'); + const second = store().ensureSegmentationForImage('img-2'); + + expect(second.id).not.toBe(first.id); + expect(store().byParentImage).toEqual({ + 'img-1': first.id, + 'img-2': second.id, + }); + }); + }); + + describe('createSegment', () => { + it('needs no storage choice: the binding is absent until it is ensured', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const segment = store().createSegment(segmentationId); + + expect(segment.representations.labelmap).toBeUndefined(); + expect(bindingOf(segmentationId, segment.id)).toBeUndefined(); + expect( + store().resolveLabelmapBinding(segmentationId, segment.id) + ).toBeFalsy(); + expect(Object.keys(store().artifactIndex)).toEqual([]); + expect(Object.keys(store().artifactMeta)).toEqual([]); + }); + + it('appends the segment to the segmentation order', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const first = store().createSegment(segmentationId); + const second = store().createSegment(segmentationId); + + expect(store().segmentations[segmentationId].order).toEqual([ + first.id, + second.id, + ]); + expect(store().getSegment(segmentationId, second.id).id).toBe(second.id); + }); + + it('defaults to visible, unlocked segments', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const segment = store().createSegment(segmentationId); + + expect(segment.visible).toBe(true); + expect(segment.locked).toBe(false); + }); + + it('cycles the categorical palette for default colors', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const colors = [ + store().createSegment(segmentationId).color, + store().createSegment(segmentationId).color, + ]; + + colors.forEach((color) => { + expect(CATEGORICAL_COLORS).toContainEqual([...color].slice(0, 3)); + expect(color[3]).toBe(255); + }); + expect([...colors[0]]).not.toEqual([...colors[1]]); + }); + + it('picks a unique default name for each new segment', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const names = [ + store().createSegment(segmentationId).name, + store().createSegment(segmentationId).name, + store().createSegment(segmentationId).name, + ]; + + expect(new Set(names).size).toBe(3); + names.forEach((name) => expect(name.length).toBeGreaterThan(0)); + }); + + it('takes a name and color from the caller', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const segment = store().createSegment(segmentationId, { + name: 'Tumor', + color: [1, 2, 3, 255], + }); + + expect(segment.name).toBe('Tumor'); + expect([...segment.color]).toEqual([1, 2, 3, 255]); + expect(store().getSegment(segmentationId, segment.id).name).toBe('Tumor'); + }); + + it('keeps duplicate and blank names', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const first = store().createSegment(segmentationId, { name: 'Tumor' }); + const second = store().createSegment(segmentationId, { name: 'Tumor' }); + const blank = store().createSegment(segmentationId, { name: '' }); + store().updateSegment(segmentationId, first.id, { name: '' }); + + expect(second.id).not.toBe(first.id); + expect(store().getSegment(segmentationId, second.id).name).toBe('Tumor'); + expect(store().getSegment(segmentationId, blank.id).name).toBe(''); + expect(store().getSegment(segmentationId, first.id).name).toBe(''); + expect(store().segmentations[segmentationId].order).toEqual([ + first.id, + second.id, + blank.id, + ]); + }); + }); + + describe('ensureLabelmapBinding', () => { + it('allocates one artifact shaped like the parent image', async () => { + await seatImage('img-1', 'Chest CT'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const { binding } = makeBoundSegment(segmentationId); + + expect(Object.keys(store().artifactIndex)).toEqual([binding.artifactId]); + expect([ + ...store().artifactIndex[binding.artifactId].getDimensions(), + ]).toEqual([...DIMENSIONS]); + expect(artifactScalars(binding.artifactId)).toHaveLength(VOXEL_COUNT); + expect(store().artifactMeta[binding.artifactId].parentImage).toBe( + 'img-1' + ); + expect( + store().artifactMeta[binding.artifactId].name.length + ).toBeGreaterThan(0); + }); + + it('covers the full parent extent in this phase', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const { binding } = makeBoundSegment(segmentationId); + + expect([...binding.extent]).toEqual([ + 0, + DIMENSIONS[0] - 1, + 0, + DIMENSIONS[1] - 1, + 0, + DIMENSIONS[2] - 1, + ]); + }); + + it('allocates a nonzero label value', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const { binding } = makeBoundSegment(segmentationId); + + expect(binding.labelValue).toBeGreaterThan(0); + }); + + it('returns the same binding on a second call and allocates nothing new', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const segment = store().createSegment(segmentationId); + + const first = store().ensureLabelmapBinding(segmentationId, segment.id); + const second = store().ensureLabelmapBinding(segmentationId, segment.id); + + expect(second.artifactId).toBe(first.artifactId); + expect(second.labelValue).toBe(first.labelValue); + expect(Object.keys(store().artifactIndex)).toHaveLength(1); + }); + + it('gives two segments of one segmentation distinct values in the shared artifact', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + + const first = makeBoundSegment(segmentationId, 'Tumor'); + const second = makeBoundSegment(segmentationId, 'Node'); + + expect(second.binding.artifactId).toBe(first.binding.artifactId); + expect(second.binding.labelValue).not.toBe(first.binding.labelValue); + expect(Object.keys(store().artifactIndex)).toHaveLength(1); + }); + + it('resolves a binding to the artifact labelmap and its label value', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const { id: segmentId, binding } = makeBoundSegment(segmentationId); + + const resolved = store().resolveLabelmapBinding( + segmentationId, + segmentId + ); + + expect(resolved!.labelValue).toBe(binding.labelValue); + expect(resolved!.labelmap.getPointData().getScalars().getData()).toBe( + artifactScalars(binding.artifactId) + ); + }); + }); + + describe('stable identity', () => { + it('keeps segment ids and bindings across rename, recolor, and reorder', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const first = makeBoundSegment(segmentationId, 'Tumor'); + const second = makeBoundSegment(segmentationId, 'Node'); + + store().updateSegment(segmentationId, first.id, { + name: 'Primary tumor', + color: [7, 8, 9, 255], + visible: false, + locked: true, + }); + store().reorderSegments(segmentationId, [second.id, first.id]); + + const renamed = store().getSegment(segmentationId, first.id); + expect(renamed.id).toBe(first.id); + expect(renamed.name).toBe('Primary tumor'); + expect([...renamed.color]).toEqual([7, 8, 9, 255]); + expect(renamed.visible).toBe(false); + expect(renamed.locked).toBe(true); + expect(renamed.representations.labelmap).toEqual(first.binding); + expect(store().getSegment(segmentationId, second.id).id).toBe(second.id); + expect(store().segmentations[segmentationId].order).toEqual([ + second.id, + first.id, + ]); + }); + + it('leaves other segments untouched when one is updated', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const first = store().createSegment(segmentationId, { name: 'Tumor' }); + const second = store().createSegment(segmentationId, { name: 'Node' }); + + store().updateSegment(segmentationId, first.id, { name: 'Renamed' }); + + expect(store().getSegment(segmentationId, second.id).name).toBe('Node'); + }); + }); + + describe('duplicate label values across artifacts', () => { + it('resolve to different segments', async () => { + await seatImage('img-1'); + await seatImage('img-2', 'PET'); + const one = store().ensureSegmentationForImage('img-1').id; + const two = store().ensureSegmentationForImage('img-2').id; + + const first = makeBoundSegment(one, 'Tumor'); + const second = makeBoundSegment(two, 'Tumor'); + + expect(second.binding.labelValue).toBe(first.binding.labelValue); + expect(second.binding.artifactId).not.toBe(first.binding.artifactId); + expect(second.id).not.toBe(first.id); + expect(store().getSegment(one, first.id).id).toBe(first.id); + expect(store().getSegment(two, second.id).id).toBe(second.id); + expect(store().resolveLabelmapBinding(one, first.id)!.labelmap).not.toBe( + store().resolveLabelmapBinding(two, second.id)!.labelmap + ); + }); + }); + + describe('deleteSegment', () => { + it('clears only the deleted segment voxels', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const doomed = makeBoundSegment(segmentationId, 'Tumor'); + const kept = makeBoundSegment(segmentationId, 'Node'); + const scalars = artifactScalars(doomed.binding.artifactId); + scalars[0] = doomed.binding.labelValue; + scalars[1] = doomed.binding.labelValue; + scalars[2] = kept.binding.labelValue; + + store().deleteSegment(segmentationId, doomed.id); + + expect([...scalars.slice(0, 3)]).toEqual([0, 0, kept.binding.labelValue]); + expect(store().segmentations[segmentationId].order).toEqual([kept.id]); + expect( + Object.keys(store().segmentations[segmentationId].segments) + ).toEqual([kept.id]); + }); + + it('releases the artifact only once no binding references it', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const first = makeBoundSegment(segmentationId, 'Tumor'); + const second = makeBoundSegment(segmentationId, 'Node'); + const { artifactId } = first.binding; + + store().deleteSegment(segmentationId, first.id); + + expect(Object.keys(store().artifactIndex)).toEqual([artifactId]); + expect(Object.keys(store().artifactMeta)).toEqual([artifactId]); + + store().deleteSegment(segmentationId, second.id); + + expect(Object.keys(store().artifactIndex)).toEqual([]); + expect(Object.keys(store().artifactMeta)).toEqual([]); + expect(store().segmentations[segmentationId].order).toEqual([]); + }); + + it('deletes an unbound segment without allocating storage', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + const segment = store().createSegment(segmentationId); + + store().deleteSegment(segmentationId, segment.id); + + expect(store().segmentations[segmentationId].order).toEqual([]); + expect(Object.keys(store().artifactIndex)).toEqual([]); + }); + }); + + describe('removeSegmentation', () => { + it('drops the segmentation and releases its artifacts', async () => { + await seatImage('img-1'); + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + makeBoundSegment(segmentationId, 'Tumor'); + + store().removeSegmentation(segmentationId); + + expect(Object.keys(store().segmentations)).toEqual([]); + expect(store().byParentImage).toEqual({}); + expect(store().getSegmentationForImage('img-1')).toBeFalsy(); + expect(Object.keys(store().artifactIndex)).toEqual([]); + expect(Object.keys(store().artifactMeta)).toEqual([]); + }); + }); + + describe('parent image deletion', () => { + it('removes the deleted image segmentation and its artifacts', async () => { + await seatImage('img-1'); + // The store subscribes to image deletion on setup, so instantiate it first. + const { id: segmentationId } = + store().ensureSegmentationForImage('img-1'); + makeBoundSegment(segmentationId, 'Tumor'); + + useImageCacheStore().removeImage('img-1'); + await nextTick(); + + expect(store().getSegmentationForImage('img-1')).toBeFalsy(); + expect(Object.keys(store().segmentations)).toEqual([]); + expect(store().byParentImage).toEqual({}); + expect(Object.keys(store().artifactIndex)).toEqual([]); + expect(Object.keys(store().artifactMeta)).toEqual([]); + }); + + it('leaves other images segmentations alone', async () => { + await seatImage('img-1'); + await seatImage('img-2', 'PET'); + const doomed = store().ensureSegmentationForImage('img-1').id; + const kept = store().ensureSegmentationForImage('img-2').id; + const keptSegment = makeBoundSegment(kept, 'Tumor'); + makeBoundSegment(doomed, 'Tumor'); + + useImageCacheStore().removeImage('img-1'); + await nextTick(); + + expect(Object.keys(store().segmentations)).toEqual([kept]); + expect(store().byParentImage).toEqual({ 'img-2': kept }); + expect(Object.keys(store().artifactIndex)).toEqual([ + keptSegment.binding.artifactId, + ]); + expect(store().getSegment(kept, keptSegment.id).id).toBe(keptSegment.id); + }); + }); +}); diff --git a/src/store/segmentations.ts b/src/store/segmentations.ts new file mode 100644 index 000000000..508a6acaf --- /dev/null +++ b/src/store/segmentations.ts @@ -0,0 +1,270 @@ +import { defineStore } from 'pinia'; +import { markRaw, reactive, toRaw } from 'vue'; +import type { RGBAColor } from '@kitware/vtk.js/types'; + +import { CATEGORICAL_COLORS } from '@/src/config'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { useIdStore } from '@/src/store/id'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { + createLabelmapFromImage, + LABELMAP_BACKGROUND_VALUE, + makeDefaultSegmentGroupName, + makeDefaultSegmentName, +} from '@/src/store/segmentGroups'; +import type { ProcessingResultSource } from '@/src/types'; +import type { + Extent3D, + LabelmapBinding, + Segment, + Segmentation, +} from '@/src/types/segmentation'; +import { removeFromArray } from '@/src/utils'; +import vtkLabelMap from '@/src/vtk/LabelMap'; + +export type ArtifactMetadata = { + parentImage: string; + name: string; + source?: ProcessingResultSource; +}; + +export type SegmentInit = { + name?: string; + color?: RGBAColor; +}; + +export type SegmentPatch = Partial>; + +const NO_NAME = '(no name)'; + +const fullExtent = (dimensions: number[]): Extent3D => [ + 0, + dimensions[0] - 1, + 0, + dimensions[1] - 1, + 0, + dimensions[2] - 1, +]; + +const pickUniqueName = ( + formatName: (index: number) => string, + taken: Iterable +) => { + const existing = new Set(taken); + let index = 1; + while (existing.has(formatName(index))) index += 1; + return formatName(index); +}; + +export const useSegmentationStore = defineStore('segmentation', () => { + const imageCacheStore = useImageCacheStore(); + + const segmentations = reactive>({}); + const byParentImage = reactive>({}); + // Internal storage layer: UI and tools reach it through this store's API only. + const artifactIndex = reactive>({}); + const artifactMeta = reactive>({}); + + let nextColorIndex = 0; + function getNextColor(): RGBAColor { + const color = CATEGORICAL_COLORS[nextColorIndex]; + nextColorIndex = (nextColorIndex + 1) % CATEGORICAL_COLORS.length; + return [...color, 255] as RGBAColor; + } + + function getSegmentation(segmentationId: string) { + const segmentation = segmentations[segmentationId]; + if (!segmentation) throw new Error('No such segmentation'); + return segmentation; + } + + function getSegment(segmentationId: string, segmentId: string) { + const segment = getSegmentation(segmentationId).segments[segmentId]; + if (!segment) throw new Error('No such segment'); + return segment; + } + + const listSegments = (segmentation: Segmentation) => + segmentation.order.map((id) => segmentation.segments[id]); + + const allBindings = () => + Object.values(segmentations) + .flatMap(listSegments) + .map((segment) => segment.representations.labelmap) + .filter((binding): binding is LabelmapBinding => !!binding); + + const bindingsForArtifact = (artifactId: string) => + allBindings().filter((binding) => binding.artifactId === artifactId); + + function releaseUnreferencedArtifact(artifactId: string) { + if (bindingsForArtifact(artifactId).length > 0) return; + delete artifactIndex[artifactId]; + delete artifactMeta[artifactId]; + } + + function getSegmentationForImage(parentImageId: string) { + const id = byParentImage[parentImageId]; + return id ? segmentations[id] : undefined; + } + + 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, + segments: {}, + order: [], + }; + byParentImage[parentImageId] = id; + return segmentations[id]; + } + + function createSegment(segmentationId: string, init?: SegmentInit) { + const segmentation = getSegmentation(segmentationId); + const id = useIdStore().nextId(); + segmentation.segments[id] = { + id, + name: + init?.name ?? + pickUniqueName( + makeDefaultSegmentName, + listSegments(segmentation).map((segment) => segment.name) + ), + color: init?.color ? ([...init.color] as RGBAColor) : getNextColor(), + visible: true, + locked: false, + representations: {}, + }; + segmentation.order.push(id); + return segmentation.segments[id]; + } + + function createArtifact(parentImageId: string) { + const imageData = imageCacheStore.getVtkImageData(parentImageId); + if (!imageData) throw new Error('No such parent image'); + + const id = useIdStore().nextId(); + artifactIndex[id] = markRaw(createLabelmapFromImage(imageData)); + artifactMeta[id] = { + parentImage: parentImageId, + name: pickUniqueName( + (index) => + makeDefaultSegmentGroupName( + imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME, + index + ), + Object.values(artifactMeta).map((meta) => meta.name) + ), + }; + return id; + } + + /** The single voxel-allocation point: no other operation creates storage. */ + function ensureLabelmapBinding(segmentationId: string, segmentId: string) { + const segment = getSegment(segmentationId, segmentId); + if (segment.representations.labelmap) + return segment.representations.labelmap; + + const segmentation = getSegmentation(segmentationId); + const artifactId = + listSegments(segmentation).find((other) => other.representations.labelmap) + ?.representations.labelmap?.artifactId ?? + createArtifact(segmentation.parentImageId); + + const used = new Set( + bindingsForArtifact(artifactId).map((binding) => binding.labelValue) + ); + let labelValue = LABELMAP_BACKGROUND_VALUE + 1; + while (used.has(labelValue)) labelValue += 1; + + segment.representations.labelmap = { + artifactId, + labelValue, + extent: fullExtent(artifactIndex[artifactId].getDimensions()), + }; + return segment.representations.labelmap; + } + + function resolveLabelmapBinding(segmentationId: string, segmentId: string) { + const binding = getSegment(segmentationId, segmentId).representations + .labelmap; + if (!binding) return undefined; + return { ...toRaw(binding), labelmap: artifactIndex[binding.artifactId] }; + } + + function updateSegment( + segmentationId: string, + segmentId: string, + patch: SegmentPatch + ) { + const segmentation = getSegmentation(segmentationId); + segmentation.segments[segmentId] = { + ...toRaw(getSegment(segmentationId, segmentId)), + ...patch, + }; + } + + function reorderSegments(segmentationId: string, order: string[]) { + getSegmentation(segmentationId).order = [...order]; + } + + function deleteSegment(segmentationId: string, segmentId: string) { + const segmentation = getSegmentation(segmentationId); + const binding = getSegment(segmentationId, segmentId).representations + .labelmap; + + removeFromArray(segmentation.order, segmentId); + delete segmentation.segments[segmentId]; + + if (!binding) return; + artifactIndex[binding.artifactId].replaceLabelValue( + binding.labelValue, + LABELMAP_BACKGROUND_VALUE + ); + releaseUnreferencedArtifact(binding.artifactId); + } + + function removeSegmentation(segmentationId: string) { + const segmentation = segmentations[segmentationId]; + if (!segmentation) return; + + const artifactIds = new Set( + listSegments(segmentation) + .map((segment) => segment.representations.labelmap?.artifactId) + .filter((id): id is string => !!id) + ); + + delete byParentImage[segmentation.parentImageId]; + delete segmentations[segmentationId]; + + artifactIds.forEach(releaseUnreferencedArtifact); + } + + onImageDeleted((deleted) => { + deleted.forEach((parentImageId) => { + const id = byParentImage[parentImageId]; + if (id) removeSegmentation(id); + }); + }); + + return { + segmentations, + byParentImage, + artifactIndex, + artifactMeta, + getSegmentationForImage, + ensureSegmentationForImage, + getSegment, + resolveLabelmapBinding, + createSegment, + ensureLabelmapBinding, + updateSegment, + reorderSegments, + deleteSegment, + removeSegmentation, + }; +}); From 93c19e6fd3d492250bb2374857b1daff0c03ee96 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 31 Aug 2026 23:13:35 -0400 Subject: [PATCH 004/221] refactor(segmentation): move segment identity to segmentation store --- src/components/SaveSegmentGroupDialog.vue | 10 +- src/components/SegmentGroupControls.vue | 33 +- src/components/SegmentList.vue | 79 ++-- src/components/SliceViewer.vue | 5 +- src/components/tools/ScalarProbe.vue | 24 +- src/components/tools/paint/PaintWidget2D.vue | 6 +- src/components/tools/polygon/PolygonTool.vue | 39 +- .../VtkSegmentationSliceRepresentation.vue | 38 +- src/config.ts | 4 +- src/io/__tests__/segNrrdMetadata.spec.ts | 55 ++- .../__tests__/restoreStateIdCollision.spec.ts | 9 +- src/io/readWriteImage.ts | 6 +- src/io/segNrrdMetadata.ts | 23 +- src/processing/applyResults.ts | 20 +- src/processing/components/JobsModule.vue | 8 +- .../components/__tests__/JobsModule.spec.ts | 16 +- src/processing/composables/useInputStaging.ts | 17 +- .../__tests__/datasetRemoveCascade.spec.ts | 40 +- src/store/__tests__/fillHoles.spec.ts | 27 +- .../legacyManifestSegmentGroups.spec.ts | 5 +- src/store/__tests__/paintProcess.spec.ts | 27 +- .../segmentGroupDescriptorlessParity.spec.ts | 72 ++-- .../segmentGroupRestoreResilience.spec.ts | 33 +- src/store/__tests__/segmentations.spec.ts | 162 ++++++++ src/store/segmentGroups.ts | 393 ++++-------------- src/store/segmentations.ts | 246 +++++++++-- src/store/tools/fillHoles.ts | 11 +- src/store/tools/paint.ts | 62 +-- src/store/tools/paintProcess.ts | 9 +- src/types/segment.ts | 9 - src/types/segmentation.ts | 13 + src/utils/bugReport.ts | 3 +- src/vtk/LabelMap/index.d.ts | 6 +- 33 files changed, 876 insertions(+), 634 deletions(-) delete mode 100644 src/types/segment.ts diff --git a/src/components/SaveSegmentGroupDialog.vue b/src/components/SaveSegmentGroupDialog.vue index 654db9f60..e78040d26 100644 --- a/src/components/SaveSegmentGroupDialog.vue +++ b/src/components/SaveSegmentGroupDialog.vue @@ -40,7 +40,7 @@ import { computed, onMounted, ref } from 'vue'; import { onKeyDown } from '@vueuse/core'; import { saveAs } from 'file-saver'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { writeSegmentation } from '@/src/io/readWriteImage'; import { useErrorMessage } from '@/src/composables/useErrorMessage'; import { sanitizeSegmentGroupFileStem } from '@/src/io/state-file/segmentGroupArchivePath'; @@ -69,7 +69,7 @@ const valid = ref(true); const saving = ref(false); const fileFormat = ref(EXTENSIONS[0]); -const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); const fileName = computed({ get: () => fileNameValue.value, set: (value: string) => { @@ -88,8 +88,8 @@ async function saveSegmentGroup() { fileNameValue.value = sanitizedFileName; const serialized = await writeSegmentation( fileFormat.value, - segmentGroupStore.dataIndex[props.id], - segmentGroupStore.metadataByID[props.id] + segmentationStore.artifactIndex[props.id], + segmentationStore.labelmapSegmentsByArtifact[props.id] ?? [] ); saveAs(new Blob([serialized]), `${sanitizedFileName}.${fileFormat.value}`); }); @@ -100,7 +100,7 @@ async function saveSegmentGroup() { onMounted(() => { // trigger form validation check so can immediately save with default value fileNameValue.value = sanitizeSegmentGroupFileStem( - segmentGroupStore.metadataByID[props.id].name + segmentationStore.artifactMeta[props.id].name ); }); diff --git a/src/components/SegmentGroupControls.vue b/src/components/SegmentGroupControls.vue index 68b6cf723..43a13789d 100644 --- a/src/components/SegmentGroupControls.vue +++ b/src/components/SegmentGroupControls.vue @@ -11,29 +11,30 @@ import { DataSelection, } from '@/src/utils/dataSelection'; import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { useGlobalLayerColorConfig } from '@/src/composables/useGlobalLayerColorConfig'; import { usePaintToolStore } from '@/src/store/tools/paint'; import { Maybe } from '@/src/types'; import { reactive, ref, computed, watch, toRaw } from 'vue'; +import type { RGBAColor } from '@kitware/vtk.js/types'; import { useMultiSelection } from '@/src/composables/useMultiSelection'; import { isCineImage } from '@/src/core/cine/isCineImage'; const UNNAMED_GROUP_NAME = 'Unnamed Segment Group'; const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); const { currentImageID } = useCurrentImage(); const dataStore = useDatasetStore(); const isCurrentImageCine = computed(() => isCineImage(currentImageID.value)); const currentSegmentGroups = computed(() => { if (!currentImageID.value) return []; - const { orderByParent, metadataByID } = segmentGroupStore; - if (!(currentImageID.value in orderByParent)) return []; - return orderByParent[currentImageID.value].map((id) => { + return segmentationStore.artifactsForImage(currentImageID.value).map((id) => { const { sampledConfig, updateConfig } = useGlobalLayerColorConfig(id); return { id, - name: metadataByID[id].name, + name: segmentationStore.artifactMeta[id].name, visibility: sampledConfig.value?.config?.blendConfig.visibility ?? true, toggleVisibility: () => { const currentBlend = sampledConfig.value!.config!.blendConfig; @@ -57,13 +58,13 @@ const currentSegmentGroupID = computed({ // clear selection if we delete the active segment group watch(currentSegmentGroups, () => { const selection = currentSegmentGroupID.value; - if (selection && !(selection in segmentGroupStore.dataIndex)) { + if (selection && !(selection in segmentationStore.artifactIndex)) { currentSegmentGroupID.value = null; } }); function deleteGroup(id: string) { - segmentGroupStore.removeGroup(id); + segmentationStore.removeArtifact(id); } // --- editing state --- // @@ -74,12 +75,12 @@ const editDialog = ref(false); const editingMetadata = computed(() => { if (!editingGroupID.value) return null; - return segmentGroupStore.metadataByID[editingGroupID.value]; + return segmentationStore.artifactMeta[editingGroupID.value]; }); const existingNames = computed(() => { return new Set( - Object.values(segmentGroupStore.metadataByID).map((meta) => meta.name) + Object.values(segmentationStore.artifactMeta).map((meta) => meta.name) ); }); @@ -126,10 +127,18 @@ function createSegmentGroup() { // copy segments from current labelmap if (currentSegmentGroupID.value) { - const metadata = - segmentGroupStore.metadataByID[currentSegmentGroupID.value]; - const copied = structuredClone(toRaw(metadata.segments)); - segmentGroupStore.updateMetadata(id, { segments: copied }); + segmentationStore.setArtifactSegments( + id, + segmentationStore + .segmentsForArtifact(currentSegmentGroupID.value) + .map((segment) => ({ + value: segment.representations.labelmap!.labelValue, + name: segment.name, + color: [...toRaw(segment.color)] as RGBAColor, + visible: segment.visible, + locked: segment.locked, + })) + ); } currentSegmentGroupID.value = id; diff --git a/src/components/SegmentList.vue b/src/components/SegmentList.vue index d277bf0df..649e350a3 100644 --- a/src/components/SegmentList.vue +++ b/src/components/SegmentList.vue @@ -2,14 +2,14 @@ import EditableChipList from '@/src/components/EditableChipList.vue'; import SegmentEditor from '@/src/components/SegmentEditor.vue'; import IsolatedDialog from '@/src/components/IsolatedDialog.vue'; +import { makeDefaultSegmentName } from '@/src/store/segmentGroups'; import { - useSegmentGroupStore, - makeDefaultSegmentName, -} from '@/src/store/segmentGroups'; + useSegmentationStore, + type SegmentPatch, +} from '@/src/store/segmentations'; import { Maybe } from '@/src/types'; import { hexaToRGBA, rgbaToHexa } from '@/src/utils/color'; import { reactive, ref, toRefs, computed, watch } from 'vue'; -import { SegmentMask } from '@/src/types/segment'; import { usePaintToolStore } from '@/src/store/tools/paint'; import type { RGBAColor } from '@kitware/vtk.js/types'; import ColorDot from '@/src/components/ColorDot.vue'; @@ -23,12 +23,35 @@ const props = defineProps({ const { groupId } = toRefs(props); -const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); const paintStore = usePaintToolStore(); -const segments = computed(() => { - return segmentGroupStore.segmentByGroupID[groupId.value] ?? []; -}); +const segmentation = computed(() => + segmentationStore.getSegmentationForArtifact(groupId.value) +); + +// The chip list and the paint selection are still keyed on label value; the +// edits below route through the segment's stable id. +const segments = computed(() => + segmentationStore.segmentsForArtifact(groupId.value).map((segment) => ({ + id: segment.id, + value: segment.representations.labelmap!.labelValue, + name: segment.name, + color: segment.color, + visible: segment.visible, + locked: segment.locked, + })) +); + +const segmentByValue = (value: number) => + segments.value.find((segment) => segment.value === value); + +function updateByValue(value: number, patch: SegmentPatch) { + const target = segmentation.value; + const segment = segmentByValue(value); + if (!target || !segment) return; + segmentationStore.updateSegment(target.id, segment.id, patch); +} // --- selection --- // @@ -40,8 +63,15 @@ const selectedSegment = computed({ }); function addNewSegment() { - const newSegment = segmentGroupStore.addSegment(groupId.value); - selectedSegment.value = newSegment.value; + const target = segmentation.value; + if (!target) return; + const segment = segmentationStore.createSegment(target.id); + const binding = segmentationStore.ensureLabelmapBinding( + target.id, + segment.id, + groupId.value + ); + selectedSegment.value = binding.labelValue; } // reset selection when necessary @@ -61,11 +91,9 @@ watch( ); const toggleVisible = (value: number) => { - const segment = segmentGroupStore.getSegment(groupId.value, value); + const segment = segmentByValue(value); if (!segment) return; - segmentGroupStore.updateSegment(groupId.value, value, { - visible: !segment.visible, - }); + updateByValue(value, { visible: !segment.visible }); }; const allVisible = computed(() => { @@ -80,9 +108,7 @@ function toggleGlobalVisible() { const visible = !allVisible.value; segments.value.forEach((seg) => { - segmentGroupStore.updateSegment(groupId.value, seg.value, { - visible, - }); + updateByValue(seg.value, { visible }); }); } @@ -90,9 +116,7 @@ function toggleGlobalLocked() { const locked = !allLocked.value; segments.value.forEach((seg) => { - segmentGroupStore.updateSegment(groupId.value, seg.value, { - locked, - }); + updateByValue(seg.value, { locked }); }); } @@ -108,7 +132,7 @@ const editDialog = ref(false); const editingSegment = computed(() => { if (editingSegmentValue.value == null) return null; - return segmentGroupStore.getSegment(groupId.value, editingSegmentValue.value); + return segmentByValue(editingSegmentValue.value) ?? null; }); const invalidNames = computed(() => { const names = new Set(segments.value.map((seg) => seg.name)); @@ -132,7 +156,7 @@ function stopEditing(commit: boolean) { ...(hexaToRGBA(editState.color).slice(0, 3) as [number, number, number]), Math.round(editState.opacity * 255), ] as RGBAColor; - segmentGroupStore.updateSegment(groupId.value, editingSegmentValue.value, { + updateByValue(editingSegmentValue.value, { name: editState.name ?? makeDefaultSegmentName(editingSegmentValue.value), color, }); @@ -142,7 +166,10 @@ function stopEditing(commit: boolean) { } function deleteSegment(value: number) { - segmentGroupStore.deleteSegment(groupId.value, value); + const target = segmentation.value; + const segment = segmentByValue(value); + if (!target || !segment) return; + segmentationStore.deleteSegment(target.id, segment.id); } function deleteEditingSegment() { @@ -157,11 +184,9 @@ function deleteEditingSegment() { * @param value - The segment value to toggle lock state for */ const toggleLock = (value: number) => { - const seg = segmentGroupStore.getSegment(groupId.value, value); + const seg = segmentByValue(value); if (seg) { - segmentGroupStore.updateSegment(groupId.value, value, { - locked: !seg.locked, - }); + updateByValue(value, { locked: !seg.locked }); } }; diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index ebc88d5da..60106d909 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -172,7 +172,7 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; import CropTool from '@/src/components/tools/crop/CropTool.vue'; @@ -267,8 +267,7 @@ onVTKEvent(currentImageData, 'onModified', () => { const segmentations = computed(() => { if (!currentImageID.value) return []; - const store = useSegmentGroupStore(); - return store.orderByParent[currentImageID.value]; + return useSegmentationStore().artifactsForImage(currentImageID.value); }); // --- selection points --- // diff --git a/src/components/tools/ScalarProbe.vue b/src/components/tools/ScalarProbe.vue index a3e317840..2e25d30b9 100644 --- a/src/components/tools/ScalarProbe.vue +++ b/src/components/tools/ScalarProbe.vue @@ -8,7 +8,7 @@ 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/store/segmentations'; import { useProbeStore } from '@/src/store/probe'; import { useImageCacheStore } from '@/src/store/image-cache'; import { NO_NAME } from '@/src/constants'; @@ -32,7 +32,7 @@ const { currentLayers, } = useCurrentImage(); const imageCacheStore = useImageCacheStore(); -const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); const probeStore = useProbeStore(); // Helper functions to build a unified sample set @@ -67,20 +67,28 @@ const getLayers = () => const getSegments = () => { if (!currentImageID.value) return []; - const parentGroups = segmentGroupStore.orderByParent[currentImageID.value]; - if (!parentGroups) return []; + const parentGroups = segmentationStore.artifactsForImage( + currentImageID.value + ); return segmentGroupsReps.value .map((rep, index) => { const groupId = parentGroups[index]; if (!groupId) return null; - const meta = segmentGroupStore.metadataByID[groupId]; + const meta = segmentationStore.artifactMeta[groupId]; return { type: 'segmentGroup', id: groupId, name: meta.name, rep, - segments: meta.segments, - image: segmentGroupStore.dataIndex[groupId], + nameByLabelValue: Object.fromEntries( + segmentationStore + .segmentsForArtifact(groupId) + .map((segment) => [ + segment.representations.labelmap!.labelValue, + segment.name, + ]) + ), + image: segmentationStore.artifactIndex[groupId], }; }) .filter(Boolean); @@ -147,7 +155,7 @@ const getImageSamples = (x: number, y: number) => { return { ...baseInfo, displayValues: scalars.map( - (v) => item.segments.byValue[v]?.name || 'Background' + (v) => item.nameByLabelValue[v] || 'Background' ), }; } diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index b870dfed1..4f3da31af 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -15,7 +15,7 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import { vtkPaintViewWidget } from '@/src/vtk/PaintWidget'; import { LPSAxisDir } from '@/src/types/lps'; import { getLPSDirections } from '@/src/utils/lps'; @@ -48,7 +48,7 @@ export default defineComponent({ const slice = computed(() => sliceInfo.value?.slice); const paintStore = usePaintToolStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); const widgetFactory = paintStore.getWidgetFactory(); const widgetState = widgetFactory.getWidgetState(); @@ -62,7 +62,7 @@ export default defineComponent({ const activeLabelmap = computed(() => { const groupId = paintStore.activeSegmentGroupID; if (!groupId) return null; - return segmentGroupStore.dataIndex[groupId] ?? null; + return segmentationStore.artifactIndex[groupId] ?? null; }); const widget = view.widgetManager.addWidget( diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index 89c19634a..dcc7461fe 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -37,24 +37,13 @@
- - - {{ currentSegmentGroup.segments.byValue[segmentID].name }} - + + {{ segment.name }}
@@ -117,8 +106,9 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import ColorDot from '@/src/components/ColorDot.vue'; -import { SegmentMask } from '@/src/types/segment'; +import type { LabelmapSegment } from '@/src/types/segmentation'; import { isCineImage } from '@/src/core/cine/isCineImage'; const useActiveToolStore = usePolygonStore; @@ -268,17 +258,24 @@ export default defineComponent({ ); const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); 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; + const [artifactId] = segmentationStore.artifactsForImage(imageId.value); + const meta = artifactId + ? segmentationStore.artifactMeta[artifactId] + : undefined; + if (!meta) return null; + return { + name: meta.name, + segments: segmentationStore.labelmapSegmentsByArtifact[artifactId], + }; }); - function rasterize(toolId: ToolID, segment: SegmentMask) { + function rasterize(toolId: ToolID, segment: LabelmapSegment) { if (!imageId.value) { throw new Error('No image ID available for rasterization'); } diff --git a/src/components/vtk/VtkSegmentationSliceRepresentation.vue b/src/components/vtk/VtkSegmentationSliceRepresentation.vue index 033920fe4..13ed270a4 100644 --- a/src/components/vtk/VtkSegmentationSliceRepresentation.vue +++ b/src/components/vtk/VtkSegmentationSliceRepresentation.vue @@ -6,10 +6,7 @@ import { LPSAxis } from '@/src/types/lps'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { SlicingMode } from '@kitware/vtk.js/Rendering/Core/ImageMapper/Constants'; import { VtkViewContext } from '@/src/components/vtk/context'; -import { - useSegmentGroupStore, - SegmentGroupMetadata, -} from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { InterpolationType } from '@kitware/vtk.js/Rendering/Core/ImageProperty/Constants'; import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; @@ -33,12 +30,15 @@ const { viewId, segmentationId, axis } = toRefs(props); const view = inject(VtkViewContext); if (!view) throw new Error('No VtkView'); -const segmentationStore = useSegmentGroupStore(); -const metadata = computed( - () => segmentationStore.metadataByID[segmentationId.value] +const segmentationStore = useSegmentationStore(); +const metadata = computed( + () => segmentationStore.artifactMeta[segmentationId.value] +); +const segments = computed( + () => segmentationStore.labelmapSegmentsByArtifact[segmentationId.value] ); const imageData = computed( - () => segmentationStore.dataIndex[segmentationId.value] + () => segmentationStore.artifactIndex[segmentationId.value] ); // redraw whenever the image changes @@ -140,11 +140,9 @@ const applySegmentColoring = () => { let maxValue = 0; - if (!metadata.value) return; // segment group just deleted + if (!segments.value) return; // segment group just deleted - const { segments } = metadata.value; - segments.order.forEach((segId) => { - const segment = segments.byValue[segId]; + segments.value.forEach((segment) => { const r = segment.color[0] || 0; const g = segment.color[1] || 0; const b = segment.color[2] || 0; @@ -180,15 +178,17 @@ watchEffect(() => { }); watchEffect(() => { - if (!metadata.value) return; // segment group just deleted + if (!segments.value) return; // segment group just deleted const thickness = outlineThickness.value; - const { segments } = metadata.value; - const largestValue = Math.max(...segments.order); - - const segThicknesses = Array.from({ length: largestValue }, (_, value) => { - const segment = segments.byValue[value + 1]; - return ((!segment || segment.visible) && thickness) || 0; + const visibleByValue = new Map( + segments.value.map((segment) => [segment.value, segment.visible]) + ); + const largestValue = Math.max(...visibleByValue.keys()); + + const segThicknesses = Array.from({ length: largestValue }, (_, index) => { + const visible = visibleByValue.get(index + 1); + return ((visible === undefined || visible) && thickness) || 0; }); sliceRep.property.setLabelOutlineThickness(segThicknesses); }); diff --git a/src/config.ts b/src/config.ts index 830ceee3c..131cbec4f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ 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 { LabelmapSegment } from '@/src/types/segmentation'; import type { LayoutConfig } from './utils/layoutParsing'; import type { ViewInfoInit } from './types/views'; import { SampleDataset } from './types'; @@ -231,7 +231,7 @@ export const ACTION_TO_KEY = { showKeyboardShortcuts: '?', } satisfies Record; -export const DEFAULT_SEGMENT_MASKS: SegmentMask[] = [ +export const DEFAULT_SEGMENT_MASKS: LabelmapSegment[] = [ { value: 1, name: 'Segment 1', diff --git a/src/io/__tests__/segNrrdMetadata.spec.ts b/src/io/__tests__/segNrrdMetadata.spec.ts index d20258076..25dac461b 100644 --- a/src/io/__tests__/segNrrdMetadata.spec.ts +++ b/src/io/__tests__/segNrrdMetadata.spec.ts @@ -8,42 +8,35 @@ import { type ParsedSegment, type DecodedSegment, } from '@/src/io/segNrrdMetadata'; -import type { SegmentGroupMetadata } from '@/src/store/segmentGroups'; +import type { LabelmapSegment } from '@/src/types/segmentation'; // 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'); @@ -56,7 +49,7 @@ describe('buildSegNrrdMetadata embeds segment names + colors', () => { }); 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'); diff --git a/src/io/import/__tests__/restoreStateIdCollision.spec.ts b/src/io/import/__tests__/restoreStateIdCollision.spec.ts index b321d3a6b..295439cb4 100644 --- a/src/io/import/__tests__/restoreStateIdCollision.spec.ts +++ b/src/io/import/__tests__/restoreStateIdCollision.spec.ts @@ -8,6 +8,7 @@ import { } from '@/src/io/import/processors/restoreStateFile'; import type { StateFileSetupResult } from '@/src/io/import/common'; import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { useImageCacheStore } from '@/src/store/image-cache'; // --------------------------------------------------------------------------- @@ -176,7 +177,9 @@ describe('restore stateID namespaces (collision)', () => { // 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); + expect(useSegmentationStore().artifactMeta[groupId].parentImage).toBe( + BASE_STORE_ID + ); // Its labelmap was built from the ARTIFACT's voxels, not the base's. const scalars = store.dataIndex[groupId] @@ -227,7 +230,9 @@ describe('restore stateID namespaces (collision)', () => { const groupId = idMap['sg-tumor']; expect(groupId).toBeDefined(); - expect(store.metadataByID[groupId].parentImage).toBe(BASE_STORE_ID); + expect(useSegmentationStore().artifactMeta[groupId].parentImage).toBe( + BASE_STORE_ID + ); expect(ioMocks.readImage).toHaveBeenCalledTimes(1); }); }); diff --git a/src/io/readWriteImage.ts b/src/io/readWriteImage.ts index 80d10fb81..987fbb497 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/types/segmentation'; 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/segNrrdMetadata.ts b/src/io/segNrrdMetadata.ts index 1222c73ac..575ce1f54 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/types/segmentation'; 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/processing/applyResults.ts b/src/processing/applyResults.ts index 0c07392d9..f0681523f 100644 --- a/src/processing/applyResults.ts +++ b/src/processing/applyResults.ts @@ -28,11 +28,12 @@ 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 type { LabelmapSegment } from '@/src/types/segmentation'; 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/store/segmentations'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useMessageStore } from '@/src/store/messages'; import { loadVolumeUrls } from '@/src/actions/loadUserFiles'; @@ -374,7 +375,7 @@ type SegmentGroupWriter = { updateSegment: ( segmentGroupID: string, segmentValue: number, - segmentUpdate: Partial> + segmentUpdate: Partial> ) => void; }; @@ -403,7 +404,7 @@ export const appApplyDependencies = (): ApplyDependencies => ({ useLayersStore().addLayer(parentSelection, childSelection), segmentGroups: { resultSourcesInScene: () => - Object.values(useSegmentGroupStore().metadataByID).map( + Object.values(useSegmentationStore().artifactMeta).map( ({ source }) => source ), convertImageToLabelmap: (childSelection, parentSelection, source) => @@ -412,12 +413,13 @@ export const appApplyDependencies = (): ApplyDependencies => ({ parentSelection, source ), - updateSegment: (segmentGroupID, segmentValue, segmentUpdate) => - useSegmentGroupStore().updateSegment( - segmentGroupID, - segmentValue, - segmentUpdate - ), + updateSegment: (artifactId, labelValue, segmentUpdate) => { + const store = useSegmentationStore(); + const segmentation = store.getSegmentationForArtifact(artifactId); + const segment = store.findSegmentByLabelValue(artifactId, labelValue); + if (!segmentation || !segment) return; + store.updateSegment(segmentation.id, segment.id, segmentUpdate); + }, }, }); diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index a01790843..f3c41dc2e 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -148,7 +148,7 @@ import { import { cropPlanesToWorldBounds } from '@/src/processing/engine/bounds'; import { useInputStaging } from '@/src/processing/composables/useInputStaging'; import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { useMessageStore } from '@/src/store/messages'; import TaskPicker from './TaskPicker.vue'; @@ -160,7 +160,7 @@ const { currentImageID } = useCurrentImage('global'); const imageCache = useImageCacheStore(); const cropStore = useCropStore(); const paintStore = usePaintToolStore(); -const segmentGroupStore = useSegmentGroupStore(); +const segmentationStore = useSegmentationStore(); const messageStore = useMessageStore(); const { @@ -498,7 +498,7 @@ function jobDisplayContext(bindings: SourceRefBindings): JobDisplayContext { const labelmapNames = Object.fromEntries( Object.entries(bindings.labelmap.groups).map(([parameterId, groupIds]) => [ parameterId, - groupIds.map((groupId) => segmentGroupStore.metadataByID[groupId].name), + groupIds.map((groupId) => segmentationStore.artifactMeta[groupId].name), ]) ); return { @@ -527,7 +527,7 @@ watchDebounced( id, crop: id ? cropStore.croppingByImageID[id] : undefined, activeSegmentGroup: paintStore.activeSegmentGroupID, - groupCount: id ? (segmentGroupStore.orderByParent[id]?.length ?? 0) : 0, + groupCount: id ? segmentationStore.artifactsForImage(id).length : 0, // Placing the first (or removing the last) tool flips the annotations // binding, so the form must revalidate. annotationCount: finishedAnnotationCount.value, diff --git a/src/processing/components/__tests__/JobsModule.spec.ts b/src/processing/components/__tests__/JobsModule.spec.ts index d3b7d8cb0..292da8ad0 100644 --- a/src/processing/components/__tests__/JobsModule.spec.ts +++ b/src/processing/components/__tests__/JobsModule.spec.ts @@ -34,7 +34,7 @@ import { useProcessingJobsStore } from '@/src/processing/store'; import { useDatasetStore } from '@/src/store/datasets'; import { useRulerStore } from '@/src/store/tools/rulers'; import { usePaintToolStore } from '@/src/store/tools/paint'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { useMessageStore } from '@/src/store/messages'; import { useViewStore } from '@/src/store/views'; @@ -472,17 +472,13 @@ describe('JobsModule — segment group staging', () => { // Painted groups, in the order the store hands them back. const seedGroups = (names: [string, string][]) => { - const store = useSegmentGroupStore(); + const store = useSegmentationStore(); names.forEach(([id, name]) => { - store.dataIndex[id] = { + store.artifactIndex[id] = { setSegments: () => {}, - } as unknown as (typeof store.dataIndex)[string]; - store.metadataByID[id] = { - name, - parentImage: 'image-1', - segments: { order: [], byValue: {} }, - }; - (store.orderByParent['image-1'] ??= []).push(id); + } as unknown as (typeof store.artifactIndex)[string]; + store.artifactMeta[id] = { name, parentImage: 'image-1' }; + (store.artifactOrderByParent['image-1'] ??= []).push(id); }); }; diff --git a/src/processing/composables/useInputStaging.ts b/src/processing/composables/useInputStaging.ts index 29baa3ccb..e1fdf3ded 100644 --- a/src/processing/composables/useInputStaging.ts +++ b/src/processing/composables/useInputStaging.ts @@ -3,7 +3,7 @@ import { computed } from 'vue'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDatasetStore } from '@/src/store/datasets'; -import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { writeSegmentation } from '@/src/io/readWriteImage'; import { getDataSourceName } from '@/src/io/import/dataSource'; import { stripExtension } from '@/src/utils/path'; @@ -63,7 +63,7 @@ export function useInputStaging() { const { currentImageID } = useCurrentImage('global'); const imageCache = useImageCacheStore(); const datasetStore = useDatasetStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); const paintStore = usePaintToolStore(); const activeDataSource = () => @@ -79,8 +79,8 @@ export function useInputStaging() { }; const segmentGroupView = (): SegmentGroupView => ({ - orderByParent: segmentGroupStore.orderByParent, - metadataByID: segmentGroupStore.metadataByID, + orderByParent: segmentationStore.artifactOrderByParent, + metadataByID: segmentationStore.artifactMeta, }); const labelmapReferenceImage = (segmentGroupId: string): InputValue | null => @@ -141,13 +141,14 @@ export function useInputStaging() { segmentGroupId: string, fileName: string ): Promise => { - const metadata = segmentGroupStore.metadataByID[segmentGroupId]; - const labelmap = segmentGroupStore.dataIndex[segmentGroupId]; + const labelmap = segmentationStore.artifactIndex[segmentGroupId]; + const segments = + segmentationStore.labelmapSegmentsByArtifact[segmentGroupId] ?? []; const referenceImage = labelmapReferenceImage(segmentGroupId); if (!referenceImage) { throw new Error('Segment group reference image has no server provenance'); } - const serialized = await writeSegmentation('seg.nrrd', labelmap, metadata); + const serialized = await writeSegmentation('seg.nrrd', labelmap, segments); return p.stageInput({ file: new Blob([serialized]), descriptor: { @@ -176,7 +177,7 @@ export function useInputStaging() { )) { const fileNames = stagedLabelmapFileNames( segmentGroupIds.map( - (groupId) => segmentGroupStore.metadataByID[groupId].name + (groupId) => segmentationStore.artifactMeta[groupId].name ) ); const uris: string[] = []; diff --git a/src/store/__tests__/datasetRemoveCascade.spec.ts b/src/store/__tests__/datasetRemoveCascade.spec.ts index 3ec1ef05a..99bb28929 100644 --- a/src/store/__tests__/datasetRemoveCascade.spec.ts +++ b/src/store/__tests__/datasetRemoveCascade.spec.ts @@ -7,6 +7,7 @@ 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/store/segmentations'; import { useRulerStore } from '@/src/store/tools/rulers'; import { useViewStore } from '@/src/store/views'; import { useCropStore } from '@/src/store/tools/crop'; @@ -73,7 +74,9 @@ describe('dataset remove — synchronous reference cascade', () => { useDatasetStore().remove('img-1'); expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); - expect(segmentGroups.metadataByID).not.toHaveProperty(groupId as string); + expect(useSegmentationStore().artifactMeta).not.toHaveProperty( + groupId as string + ); }); it('clears ALL segment groups when an image has several (no splice-skip)', () => { @@ -95,11 +98,44 @@ describe('dataset remove — synchronous reference cascade', () => { expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); [groupA, groupB, groupC].forEach((id) => { - expect(segmentGroups.metadataByID).not.toHaveProperty(id as string); + expect(useSegmentationStore().artifactMeta).not.toHaveProperty( + id as string + ); expect(segmentGroups.dataIndex).not.toHaveProperty(id as string); }); }); + it('removes the segmentation and its artifacts with the parent image', () => { + seatImage('img-1', 'CT'); + const segmentGroups = useSegmentGroupStore(); + const segmentations = useSegmentationStore(); + const artifactId = segmentGroups.newLabelmapFromImage('img-1') as string; + const segmentation = segmentations.getSegmentationForImage('img-1'); + expect(segmentation).toBeTruthy(); + expect(segmentations.artifactMeta).toHaveProperty(artifactId); + + useDatasetStore().remove('img-1'); + + expect(segmentations.getSegmentationForImage('img-1')).toBeFalsy(); + expect(segmentations.segmentations).not.toHaveProperty(segmentation!.id); + expect(segmentations.artifactMeta).not.toHaveProperty(artifactId); + expect(segmentations.artifactIndex).not.toHaveProperty(artifactId); + }); + + it('leaves another image segmentation intact', () => { + seatImage('img-1', 'CT'); + seatImage('img-2', 'PET'); + const segmentGroups = useSegmentGroupStore(); + const segmentations = useSegmentationStore(); + segmentGroups.newLabelmapFromImage('img-1'); + const keptArtifact = segmentGroups.newLabelmapFromImage('img-2') as string; + + useDatasetStore().remove('img-1'); + + expect(segmentations.getSegmentationForImage('img-2')).toBeTruthy(); + expect(segmentations.artifactMeta).toHaveProperty(keptArtifact); + }); + it('clears annotation tools bound to the removed image', () => { seatImage('img-1', 'CT'); const rulerStore = useRulerStore(); diff --git a/src/store/__tests__/fillHoles.spec.ts b/src/store/__tests__/fillHoles.spec.ts index 66ddb2195..500048f29 100644 --- a/src/store/__tests__/fillHoles.spec.ts +++ b/src/store/__tests__/fillHoles.spec.ts @@ -99,22 +99,19 @@ describe('Fill Holes store', () => { }); 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 groupId = segmentGroupStore.addLabelmap( + labelMap, + { name: 'Test group', parentImage: parentImageID }, + [ + { + 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' diff --git a/src/store/__tests__/legacyManifestSegmentGroups.spec.ts b/src/store/__tests__/legacyManifestSegmentGroups.spec.ts index c66fd86bf..8a827c370 100644 --- a/src/store/__tests__/legacyManifestSegmentGroups.spec.ts +++ b/src/store/__tests__/legacyManifestSegmentGroups.spec.ts @@ -3,6 +3,7 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDatasetStore } from '@/src/store/datasets'; import { ManifestSchema } from '@/src/io/state-file/schema'; @@ -96,7 +97,9 @@ describe('segmentGroups.deserialize — legacy manifests without `datasets`', () expect(skipped).toEqual([]); expect(idMap['sg-tumor']).toBeDefined(); expect( - Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') + Object.values(useSegmentationStore().artifactMeta).some( + (m) => m.name === 'sg-tumor' + ) ).toBe(true); // The consumed artifact dataset is removed after conversion. expect(removeSpy).toHaveBeenCalledTimes(1); diff --git a/src/store/__tests__/paintProcess.spec.ts b/src/store/__tests__/paintProcess.spec.ts index b40d0053f..bafd42438 100644 --- a/src/store/__tests__/paintProcess.spec.ts +++ b/src/store/__tests__/paintProcess.spec.ts @@ -39,22 +39,19 @@ function deferred() { 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, - }, + const groupId = segmentGroupStore.addLabelmap( + labelMap, + { name: 'Test group', parentImage: 'image-1' }, + [ + { + value: 1, + name: 'Segment 1', + color: [255, 0, 0, 255], + visible: true, + locked: false, }, - }, - }); + ] + ); return { groupId, labelMap }; } diff --git a/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts b/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts index 427b2c6e9..45a63f5a2 100644 --- a/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts +++ b/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts @@ -3,6 +3,7 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import { useImageCacheStore } from '@/src/store/image-cache'; import { leafStateId } from '@/src/io/import/dataSource'; import { resolveArtifactRestoreSources } from '@/src/io/import/processors/restoreStateFile'; @@ -120,17 +121,38 @@ const seat = ( headerMetadata?: Map ) => useImageCacheStore().addVTKImageData(image, name, { id, headerMetadata }); +// The catalog now lives in the segmentation store: the segments bound to one +// artifact, in segmentation order. Identity is a stable id, so parity compares +// the descriptive fields plus the label value the binding carries. +const catalogFor = (parentImageId: string, artifactId: string) => { + const segmentation = + useSegmentationStore().getSegmentationForImage(parentImageId); + if (!segmentation) return []; + return segmentation.order + .map((id) => segmentation.segments[id]) + .filter( + (segment) => segment.representations.labelmap?.artifactId === artifactId + ) + .map((segment) => ({ + name: segment.name, + color: [...segment.color], + visible: segment.visible, + locked: segment.locked, + labelValue: segment.representations.labelmap!.labelValue, + })); +}; + // The LIVE path: what convertImageToLabelmap builds for this labelmap. async function liveCatalog(segmentMetadata?: Map) { setActivePinia(createPinia()); seat('parent-img', 'CT Chest', makeParentImage()); seat('child-img', 'Tumor.seg.nrrd', makeLabelmapImage(), segmentMetadata); const store = useSegmentGroupStore(); - const [groupId] = await store.convertImageToLabelmap( + const [artifactId] = await store.convertImageToLabelmap( 'child-img', 'parent-img' ); - return JSON.parse(JSON.stringify(store.metadataByID[groupId].segments)); + return catalogFor('parent-img', artifactId); } // The COLD path: what deserialize builds from a descriptor-less composed @@ -155,9 +177,9 @@ async function coldCatalog(segmentMetadata?: Map) { }, resolveArtifactRestoreSources(manifest) ); - const groupId = idMap['sg-tumor']; - expect(groupId).toBeDefined(); - return JSON.parse(JSON.stringify(store.metadataByID[groupId].segments)); + const artifactId = idMap['sg-tumor']; + expect(artifactId).toBeDefined(); + return catalogFor('parent-store', artifactId); } describe('descriptor-less segment catalogs: cold restore == live conversion (parity pin)', () => { @@ -171,9 +193,11 @@ describe('descriptor-less segment catalogs: cold restore == live conversion (par // Sanity on the live shape: the full non-background enumeration got // default names/colors — not an empty catalog. - expect(live.order).toEqual([1, 2]); - expect(live.byValue[1].name).toBe('Segment 1'); - expect(live.byValue[2].name).toBe('Segment 2'); + expect(live.map((segment) => segment.labelValue)).toEqual([1, 2]); + expect(live.map((segment) => segment.name)).toEqual([ + 'Segment 1', + 'Segment 2', + ]); expect(cold).toEqual(live); }); @@ -190,8 +214,10 @@ describe('descriptor-less segment catalogs: cold restore == live conversion (par // The described value carries its embedded name; the undescribed value // still gets its default (merge, not replace). - expect(live.byValue[2].name).toBe('Tumor core'); - expect(live.byValue[1].name).toBe('Segment 1'); + const named = (labelValue: number) => + live.find((segment) => segment.labelValue === labelValue)?.name; + expect(named(2)).toBe('Tumor core'); + expect(named(1)).toBe('Segment 1'); expect(cold).toEqual(live); }); @@ -220,10 +246,10 @@ describe('descriptor-less segment catalogs: cold restore == live conversion (par { 'ds-ct': 'parent-store' } ); - const segments = store.metadataByID[idMap['sg-tumor']].segments; - expect(segments.order).toEqual([1, 2]); - expect(segments.byValue[1].name).toBe('Segment 1'); - expect(segments.byValue[2]).toMatchObject({ + const segments = catalogFor('parent-store', idMap['sg-tumor']); + expect(segments.map((segment) => segment.labelValue)).toEqual([1, 2]); + expect(segments[0].name).toBe('Segment 1'); + expect(segments[1]).toMatchObject({ name: 'Tumor core', color: [255, 0, 0, 255], }); @@ -235,16 +261,14 @@ describe('descriptor-less segment catalogs: cold restore == live conversion (par seat('child-img', 'Sparse.seg.nrrd', makeSparseLabelmapImage()); const store = useSegmentGroupStore(); - const [groupId] = await store.convertImageToLabelmap( + const [artifactId] = await store.convertImageToLabelmap( 'child-img', 'parent-img' ); - expect(store.metadataByID[groupId].segments.order).toEqual([1, 255]); - expect(Object.keys(store.metadataByID[groupId].segments.byValue)).toEqual([ - '1', - '255', - ]); + expect( + catalogFor('parent-img', artifactId).map((segment) => segment.labelValue) + ).toEqual([1, 255]); }); it('enumerates no segments for an all-background labelmap', async () => { @@ -256,14 +280,14 @@ describe('descriptor-less segment catalogs: cold restore == live conversion (par seat('child-img', 'Empty.seg.nrrd', makeParentImage()); const store = useSegmentGroupStore(); - const [groupId] = await store.convertImageToLabelmap( + const [artifactId] = await store.convertImageToLabelmap( 'child-img', 'parent-img' ); - expect(store.metadataByID[groupId].segments.order).toEqual([]); - expect(Object.keys(store.metadataByID[groupId].segments.byValue)).toEqual( - [] + expect(catalogFor('parent-img', artifactId)).toEqual([]); + expect(useSegmentationStore().artifactMeta[artifactId]?.parentImage).toBe( + 'parent-img' ); }); }); diff --git a/src/store/__tests__/segmentGroupRestoreResilience.spec.ts b/src/store/__tests__/segmentGroupRestoreResilience.spec.ts index 1020001ad..4b5defa9b 100644 --- a/src/store/__tests__/segmentGroupRestoreResilience.spec.ts +++ b/src/store/__tests__/segmentGroupRestoreResilience.spec.ts @@ -3,6 +3,7 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import { leafStateId } from '@/src/io/import/dataSource'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDatasetStore } from '@/src/store/datasets'; @@ -106,6 +107,22 @@ function makeEmptyScalarsImage() { // Mirrors production: the restore setup resolves each group's artifact state // source from the manifest (resolveArtifactRestoreSources, the single-owner // policy) and hands it to deserialize alongside the dataIDMap. +// Restored artifacts register in the segmentation store: name/parent live in +// artifactMeta, the segment catalog in the parent image's segmentation. +const artifactNames = () => + Object.values(useSegmentationStore().artifactMeta).map((meta) => meta.name); + +const catalogFor = (parentImageId: string, artifactId: string) => { + const segmentation = + useSegmentationStore().getSegmentationForImage(parentImageId); + if (!segmentation) return []; + return segmentation.order + .map((id) => segmentation.segments[id]) + .filter( + (segment) => segment.representations.labelmap?.artifactId === artifactId + ); +}; + const restoreGroups = ( manifest: Manifest, stateFiles: { archivePath: string; file: File }[], @@ -145,6 +162,11 @@ describe('segmentGroups.deserialize — resilient restore', () => { expect(skipped).toEqual([ { name: 'sg-tumor', reason: 'artifact source unavailable' }, ]); + const restored = catalogFor('store-ct', idMap['sg-liver']); + expect(restored.map((segment) => segment.name)).toEqual(['Tumor']); + expect(restored[0].representations.labelmap!.labelValue).toBe(1); + expect([...restored[0].color]).toEqual([255, 0, 0, 255]); + expect(artifactNames()).toEqual(['sg-liver']); }); it('skips a group whose parent base never resolved', async () => { @@ -157,7 +179,7 @@ describe('segmentGroups.deserialize — resilient restore', () => { ); expect(idMap).toEqual({}); - expect(Object.keys(useSegmentGroupStore().metadataByID)).toEqual([]); + expect(Object.keys(useSegmentationStore().artifactMeta)).toEqual([]); expect(skipped).toEqual([ { name: 'sg-tumor', reason: 'parent image did not load' }, ]); @@ -211,7 +233,6 @@ describe('segmentGroups.deserialize — resilient restore', () => { seatImage('store-ct', 'CT Chest'); seatImage('store-liver', 'Liver.seg.nrrd'); - const store = useSegmentGroupStore(); await completeStateFileRestore( manifestWith([ group('sg-tumor', { dataSourceId: 3 }, 'ds-missing'), @@ -221,12 +242,8 @@ describe('segmentGroups.deserialize — resilient restore', () => { { 'ds-ct': 'store-ct', [leafStateId(4)]: 'store-liver' } ); - expect( - Object.values(store.metadataByID).some((m) => m.name === 'sg-liver') - ).toBe(true); - expect( - Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') - ).toBe(false); + expect(artifactNames()).toContain('sg-liver'); + expect(artifactNames()).not.toContain('sg-tumor'); const warning = useMessageStore().messages.find( (message) => message.title === 'Some scene content could not be restored' diff --git a/src/store/__tests__/segmentations.spec.ts b/src/store/__tests__/segmentations.spec.ts index a4f9531c2..d46f6acd5 100644 --- a/src/store/__tests__/segmentations.spec.ts +++ b/src/store/__tests__/segmentations.spec.ts @@ -6,6 +6,7 @@ import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import { CATEGORICAL_COLORS } from '@/src/config'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import { useSegmentationStore } from '@/src/store/segmentations'; const DIMENSIONS = [4, 4, 2] as const; @@ -26,8 +27,42 @@ async function seatImage(id: string, name = 'CT') { return id; } +/** Seats a child image whose voxels already carry label values. */ +async function seatLabelValues( + id: string, + values: Uint8Array, + headerMetadata?: Map +) { + 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 })); + image.computeTransforms(); + useImageCacheStore().addVTKImageData(image, `${id}.seg.nrrd`, { + id, + headerMetadata, + }); + await nextTick(); + return id; +} + const store = () => useSegmentationStore(); +/** The catalog a consumer builds for one artifact: segments bound to it, in order. */ +const segmentsForArtifact = (parentImageId: string, artifactId: string) => { + const segmentation = store().getSegmentationForImage(parentImageId); + if (!segmentation) return []; + return segmentation.order + .map((id) => segmentation.segments[id]) + .filter( + (segment) => segment.representations.labelmap?.artifactId === artifactId + ); +}; + +const labelValuesOf = (segments: ReturnType) => + segments.map((segment) => segment.representations.labelmap!.labelValue); + const artifactScalars = (artifactId: string) => store().artifactIndex[artifactId].getPointData().getScalars().getData(); @@ -473,4 +508,131 @@ describe('segmentation store', () => { expect(store().getSegment(kept, keptSegment.id).id).toBe(keptSegment.id); }); }); + + describe('conversion and decode', () => { + it('creates one bound segment per discovered label value', async () => { + await seatImage('parent-img', 'Chest CT'); + const values = new Uint8Array(VOXEL_COUNT); + values.fill(1, 4, 12); + values.fill(2, 12); + await seatLabelValues('child-img', values); + + const [artifactId] = await useSegmentGroupStore().convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + + const segmentation = store().getSegmentationForImage('parent-img'); + expect(segmentation).toBeTruthy(); + expect(segmentation!.parentImageId).toBe('parent-img'); + const segments = segmentsForArtifact('parent-img', artifactId); + expect(segments.map((segment) => segment.name)).toEqual([ + 'Segment 1', + 'Segment 2', + ]); + expect(labelValuesOf(segments)).toEqual([1, 2]); + segments.forEach((segment) => { + expect(segment.visible).toBe(true); + expect(segment.locked).toBe(false); + expect(segment.id.length).toBeGreaterThan(0); + }); + }); + + it('binds converted segments to the artifact holding the voxels', async () => { + await seatImage('parent-img', 'Chest CT'); + const values = new Uint8Array(VOXEL_COUNT); + values.fill(1, 4, 12); + values.fill(2, 12); + await seatLabelValues('child-img', values); + + const [artifactId] = await useSegmentGroupStore().convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + + const segments = segmentsForArtifact('parent-img', artifactId); + expect(segments).toHaveLength(2); + const resolved = store().resolveLabelmapBinding( + store().getSegmentationForImage('parent-img')!.id, + segments[0].id + ); + expect(resolved?.artifactId).toBe(artifactId); + expect(resolved?.labelmap).toBe(store().artifactIndex[artifactId]); + expect([...artifactScalars(artifactId)]).toEqual([...values]); + expect(store().artifactMeta[artifactId].parentImage).toBe('parent-img'); + expect(store().artifactMeta[artifactId].name.length).toBeGreaterThan(0); + }); + + it('preserves names and colors from embedded seg.nrrd metadata', async () => { + await seatImage('parent-img', 'Chest CT'); + const values = new Uint8Array(VOXEL_COUNT); + values.fill(1, 4, 12); + values.fill(2, 12); + await seatLabelValues( + 'child-img', + values, + new Map([ + ['Segment0_LabelValue', '2'], + ['Segment0_Name', 'Tumor core'], + ['Segment0_Color', '1 0 0'], + ]) + ); + + const [artifactId] = await useSegmentGroupStore().convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + + const segments = segmentsForArtifact('parent-img', artifactId); + expect(labelValuesOf(segments)).toEqual([1, 2]); + const byLabelValue = (labelValue: number) => + segments.find( + (segment) => + segment.representations.labelmap!.labelValue === labelValue + )!; + expect(byLabelValue(2).name).toBe('Tumor core'); + expect([...byLabelValue(2).color]).toEqual([255, 0, 0, 255]); + // Merge, not replace: an undescribed value keeps its default. + expect(byLabelValue(1).name).toBe('Segment 1'); + }); + + it('gives a second conversion of the same parent its own artifact', async () => { + await seatImage('parent-img', 'Chest CT'); + const values = new Uint8Array(VOXEL_COUNT); + values.fill(1, 4, 12); + await seatLabelValues('child-a', values); + await seatLabelValues('child-b', values); + const segmentGroups = useSegmentGroupStore(); + + const [first] = await segmentGroups.convertImageToLabelmap( + 'child-a', + 'parent-img' + ); + const [second] = await segmentGroups.convertImageToLabelmap( + 'child-b', + 'parent-img' + ); + + expect(second).not.toBe(first); + expect(Object.keys(store().segmentations)).toHaveLength(1); + const fromFirst = segmentsForArtifact('parent-img', first); + const fromSecond = segmentsForArtifact('parent-img', second); + expect(labelValuesOf(fromFirst)).toEqual([1]); + expect(labelValuesOf(fromSecond)).toEqual([1]); + expect(fromSecond[0].id).not.toBe(fromFirst[0].id); + }); + + it('enumerates no segments for an all-background labelmap', async () => { + await seatImage('parent-img', 'Chest CT'); + await seatLabelValues('child-img', new Uint8Array(VOXEL_COUNT)); + + const [artifactId] = await useSegmentGroupStore().convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + + expect(segmentsForArtifact('parent-img', artifactId)).toEqual([]); + expect(store().artifactMeta[artifactId]?.parentImage).toBe('parent-img'); + }); + }); }); diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts index 98905ef69..9c2295333 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -1,15 +1,11 @@ -import { computed, reactive, ref, toRaw, watch } from 'vue'; +import { ref } 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 type { LabelmapSegment } from '@/src/types/segmentation'; import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config'; import { readImage, writeSegmentation } from '@/src/io/readWriteImage'; import { @@ -24,15 +20,14 @@ import { } from '@/src/utils/dataSelection'; import vtkImageExtractComponents from '@/src/utils/imageExtractComponentsFilter'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { + useSegmentationStore, + type ArtifactMetadata, +} from '@/src/store/segmentations'; 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 { StateFile, Manifest, SegmentGroup } from '../io/state-file/schema'; import { makeSegmentGroupArchivePath } from '../io/state-file/segmentGroupArchivePath'; import { FileEntry } from '../io/types'; import { ensureSameSpace } from '../io/resample/resample'; @@ -48,17 +43,6 @@ 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( @@ -128,133 +112,52 @@ export function extractEachComponent(input: vtkImageData) { }); } +/** The artifact layer: labelmap bytes, decode, and the legacy wire format. */ export const useSegmentGroupStore = defineStore('segmentGroup', () => { type _This = ReturnType; const imageCacheStore = useImageCacheStore(); + const segmentationStore = useSegmentationStore(); - 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) - ); - } + // One artifact index for the app, owned by the segmentation store; exposed + // here for the edit paths still keyed on segment-group ids until C6. + const { artifactIndex: dataIndex, artifactOrderByParent: orderByParent } = + segmentationStore; /** * Adds a given image + metadata as a labelmap. */ function addLabelmap( - this: _This, labelmap: vtkLabelMap, - metadata: SegmentGroupMetadata + metadata: ArtifactMetadata, + segments: LabelmapSegment[] = [] ) { - const id = useIdStore().nextId(); - - dataIndex[id] = labelmap; - metadataByID[id] = metadata; - orderByParent.value[metadata.parentImage] ??= []; - orderByParent.value[metadata.parentImage].push(id); - + const id = segmentationStore.registerArtifact(labelmap, metadata); + segmentationStore.setArtifactSegments(id, segments); 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) { + function newLabelmapFromImage(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 + const id = segmentationStore.createArtifactForImage(parentID); + segmentationStore.setArtifactSegments( + id, + structuredClone(DEFAULT_SEGMENT_MASKS) ); - - return addLabelmap.call(this, labelmap, { - name, - parentImage: parentID, - segments: { order, byValue: byKey }, - }); + return id; } /** * 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]; + segmentationStore.removeArtifact(id); } let nextColorIndex = 0; @@ -337,16 +240,16 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { /** * Converts an image to a labelmap. * - * Returns the created segment-group id(s) — one per component of the source + * Returns the created artifact 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 + * adds so the caller can act on the created artifacts synchronously afterwards * (corroboration/present + descriptor application key off the - * returned ids rather than racing `orderByParent`). + * returned ids rather than racing the artifact order). */ async function convertImageToLabelmap( imageID: DataSelection, parentID: DataSelection, - source?: SegmentGroupMetadata['source'] + source?: ArtifactMetadata['source'] ): Promise { if (imageID === parentID) throw new Error('Cannot convert an image to be a labelmap of itself'); @@ -395,119 +298,40 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { labelmapImage, component ); - const { order, byKey } = normalizeForStore(segments, 'value'); - const segmentGroupStore = useSegmentGroupStore(); - const name = pickUniqueName( + const name = segmentationStore.pickUniqueArtifactName( (index: number) => `${baseName} ${numberer(index)}`, parentID ); - const id = segmentGroupStore.addLabelmap(labelmapImage, { - name, - parentImage: parentID, - segments: { order, byValue: byKey }, - ...(source ? { source } : {}), - }); - return id; + return addLabelmap( + labelmapImage, + { + name, + parentImage: parentID, + ...(source ? { source } : {}), + }, + segments as LabelmapSegment[] + ); }) ); } /** - * Updates a labelmap's metadata - * @param segmentGroupID - * @param metadata + * Updates an artifact's 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> + artifactId: string, + metadata: 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 - ); + segmentationStore.updateArtifactMeta(artifactId, metadata); } const saveFormat = ref('vti'); + // wire-format shim, replaced in C7 + const legacySegmentDescriptors = (artifactId: string) => + segmentationStore.labelmapSegmentsByArtifact[artifactId] ?? []; + /** * Serializes the store's state. */ @@ -515,14 +339,14 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { const { zip } = state; const usedArchivePaths = new Set(); - // orderByParent is implicitly preserved based on + // Artifact order per parent image 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]; + const parents = Object.keys(orderByParent); + const serialized = parents.flatMap((parentID) => + orderByParent[parentID].map((id) => { + const metadata = segmentationStore.artifactMeta[id]; + const segments = legacySegmentDescriptors(id); return { id, path: makeSegmentGroupArchivePath( @@ -530,23 +354,36 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { saveFormat.value, usedArchivePaths ), + segments, + // wire-format shim, replaced in C7 metadata: { - ...metadata, + name: metadata.name, parentImage: metadata.parentImage, + segments: { + order: segments.map((segment) => segment.value), + byValue: Object.fromEntries( + segments.map((segment) => [segment.value, segment]) + ), + }, + ...(metadata.source ? { source: metadata.source } : {}), }, }; - }); - }); + }) + ); - state.manifest.segmentGroups = serialized; + state.manifest.segmentGroups = serialized.map(({ id, path, metadata }) => ({ + id, + path, + metadata, + })); // save labelmap images await Promise.all( - serialized.map(async ({ id, path }) => { + serialized.map(async ({ id, path, segments }) => { const serializedImage = await writeSegmentation( saveFormat.value, dataIndex[id], - metadataByID[id] + segments ); zip.file(path, serializedImage); }) @@ -580,7 +417,7 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { } // First restore the data, then restore the store. - // This preserves ordering from orderByParent. + // This preserves the per-parent artifact ordering. // `path` is authoritative for bytes when present: a re-saved // zip carries the archive bytes AND the provenance `dataSourceId`, but @@ -693,22 +530,18 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { // 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 }; + const wireSegments = segmentGroup.metadata.segments; + const segments = wireSegments + ? wireSegments.order + .map((value) => wireSegments.byValue[String(value)]) + .filter((segment) => !!segment) + : await decodeSegments(storeId, labelmapImage, 0, headerMetadata); + + return { + segmentGroup, + labelmapImage, + segments: segments as LabelmapSegment[], + }; } catch { // A parse/read failure skips just this group — never rejects the // whole restore; the survivors still attach. Recorded (not silent) so @@ -727,75 +560,31 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { labelmapResults.forEach((result) => { if (!result) return; - const { segmentGroup, id: newID, segments } = result; - segmentGroupIDMap[segmentGroup.id] = newID; - + const { segmentGroup, labelmapImage, segments } = result; + const { name, source } = segmentGroup.metadata; const parentImage = dataIDMap[segmentGroup.metadata.parentImage]; - metadataByID[newID] = { ...segmentGroup.metadata, parentImage, segments }; - orderByParent.value[parentImage] ??= []; - orderByParent.value[parentImage].push(newID); + segmentGroupIDMap[segmentGroup.id] = addLabelmap( + labelmapImage, + { name, parentImage, ...(source ? { source } : {}) }, + segments + ); }); 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/segmentations.ts b/src/store/segmentations.ts index 508a6acaf..99e11507f 100644 --- a/src/store/segmentations.ts +++ b/src/store/segmentations.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia'; -import { markRaw, reactive, toRaw } from 'vue'; +import { computed, markRaw, reactive, toRaw, watch } from 'vue'; import type { RGBAColor } from '@kitware/vtk.js/types'; import { CATEGORICAL_COLORS } from '@/src/config'; @@ -16,6 +16,7 @@ import type { ProcessingResultSource } from '@/src/types'; import type { Extent3D, LabelmapBinding, + LabelmapSegment, Segment, Segmentation, } from '@/src/types/segmentation'; @@ -46,14 +47,11 @@ const fullExtent = (dimensions: number[]): Extent3D => [ dimensions[2] - 1, ]; -const pickUniqueName = ( - formatName: (index: number) => string, - taken: Iterable -) => { +const pickUniqueSegmentName = (taken: Iterable) => { const existing = new Set(taken); let index = 1; - while (existing.has(formatName(index))) index += 1; - return formatName(index); + while (existing.has(makeDefaultSegmentName(index))) index += 1; + return makeDefaultSegmentName(index); }; export const useSegmentationStore = defineStore('segmentation', () => { @@ -64,6 +62,7 @@ export const useSegmentationStore = defineStore('segmentation', () => { // Internal storage layer: UI and tools reach it through this store's API only. const artifactIndex = reactive>({}); const artifactMeta = reactive>({}); + const artifactOrderByParent = reactive>({}); let nextColorIndex = 0; function getNextColor(): RGBAColor { @@ -72,6 +71,26 @@ export const useSegmentationStore = defineStore('segmentation', () => { return [...color, 255] as RGBAColor; } + // Names keep counting up per parent image so a deleted artifact's name is + // not immediately handed to the next one. Cleared by the deletion cascade. + const nextDefaultIndex: Record = Object.create(null); + + function pickUniqueArtifactName( + formatName: (index: number) => string, + parentImageId: string + ) { + const existing = new Set( + Object.values(artifactMeta).map((meta) => meta.name) + ); + let name = ''; + do { + const nameIndex = nextDefaultIndex[parentImageId] ?? 1; + nextDefaultIndex[parentImageId] = nameIndex + 1; + name = formatName(nameIndex); + } while (existing.has(name)); + return name; + } + function getSegmentation(segmentationId: string) { const segmentation = segmentations[segmentationId]; if (!segmentation) throw new Error('No such segmentation'); @@ -96,12 +115,6 @@ export const useSegmentationStore = defineStore('segmentation', () => { const bindingsForArtifact = (artifactId: string) => allBindings().filter((binding) => binding.artifactId === artifactId); - function releaseUnreferencedArtifact(artifactId: string) { - if (bindingsForArtifact(artifactId).length > 0) return; - delete artifactIndex[artifactId]; - delete artifactMeta[artifactId]; - } - function getSegmentationForImage(parentImageId: string) { const id = byParentImage[parentImageId]; return id ? segmentations[id] : undefined; @@ -130,8 +143,7 @@ export const useSegmentationStore = defineStore('segmentation', () => { id, name: init?.name ?? - pickUniqueName( - makeDefaultSegmentName, + pickUniqueSegmentName( listSegments(segmentation).map((segment) => segment.name) ), color: init?.color ? ([...init.color] as RGBAColor) : getNextColor(), @@ -143,37 +155,139 @@ export const useSegmentationStore = defineStore('segmentation', () => { return segmentation.segments[id]; } - function createArtifact(parentImageId: string) { + const artifactsForImage = (parentImageId: string) => + artifactOrderByParent[parentImageId] ?? []; + + function getSegmentationForArtifact(artifactId: string) { + const parentImage = artifactMeta[artifactId]?.parentImage; + return parentImage ? getSegmentationForImage(parentImage) : undefined; + } + + /** The ordered segments whose labelmap binding points at one artifact. */ + function segmentsForArtifact(artifactId: string) { + const segmentation = getSegmentationForArtifact(artifactId); + if (!segmentation) return []; + return listSegments(segmentation).filter( + (segment) => segment.representations.labelmap?.artifactId === artifactId + ); + } + + function findSegmentByLabelValue(artifactId: string, labelValue: number) { + return segmentsForArtifact(artifactId).find( + (segment) => segment.representations.labelmap?.labelValue === labelValue + ); + } + + function registerArtifact(labelmap: vtkLabelMap, meta: ArtifactMetadata) { + const id = useIdStore().nextId(); + artifactIndex[id] = markRaw(labelmap); + artifactMeta[id] = { ...meta }; + artifactOrderByParent[meta.parentImage] ??= []; + artifactOrderByParent[meta.parentImage].push(id); + return id; + } + + /** Allocates an empty labelmap shaped like the parent image. */ + function createArtifactForImage(parentImageId: string) { const imageData = imageCacheStore.getVtkImageData(parentImageId); if (!imageData) throw new Error('No such parent image'); - const id = useIdStore().nextId(); - artifactIndex[id] = markRaw(createLabelmapFromImage(imageData)); - artifactMeta[id] = { + const baseName = + imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME; + return registerArtifact(createLabelmapFromImage(imageData), { parentImage: parentImageId, - name: pickUniqueName( - (index) => - makeDefaultSegmentGroupName( - imageCacheStore.getImageMetadata(parentImageId)?.name ?? NO_NAME, - index - ), - Object.values(artifactMeta).map((meta) => meta.name) + name: pickUniqueArtifactName( + (index) => makeDefaultSegmentGroupName(baseName, index), + parentImageId ), - }; - return id; + }); + } + + function updateArtifactMeta( + artifactId: string, + patch: Partial + ) { + const meta = artifactMeta[artifactId]; + if (!meta) throw new Error('No such artifact'); + artifactMeta[artifactId] = { ...meta, ...patch }; + } + + function detachSegment(segmentation: Segmentation, segmentId: string) { + removeFromArray(segmentation.order, segmentId); + delete segmentation.segments[segmentId]; + } + + function removeArtifact(artifactId: string) { + const meta = artifactMeta[artifactId]; + if (!meta) return; + + const segmentation = getSegmentationForImage(meta.parentImage); + if (segmentation) { + segmentsForArtifact(artifactId).forEach((segment) => + detachSegment(segmentation, segment.id) + ); + } + + removeFromArray(artifactOrderByParent[meta.parentImage] ?? [], artifactId); + delete artifactIndex[artifactId]; + delete artifactMeta[artifactId]; + } + + function releaseUnreferencedArtifact(artifactId: string) { + if (bindingsForArtifact(artifactId).length > 0) return; + removeArtifact(artifactId); + } + + /** + * Replaces an artifact's segment catalog with one segment per given label + * value. Voxels are untouched: this is a catalog operation. + */ + function setArtifactSegments( + artifactId: string, + descriptors: LabelmapSegment[] + ) { + const meta = artifactMeta[artifactId]; + if (!meta) throw new Error('No such artifact'); + + const segmentation = ensureSegmentationForImage(meta.parentImage); + segmentsForArtifact(artifactId).forEach((segment) => + detachSegment(segmentation, segment.id) + ); + + const extent = fullExtent(artifactIndex[artifactId].getDimensions()); + descriptors.forEach((descriptor) => { + const segment = createSegment(segmentation.id, { + name: descriptor.name, + color: [...descriptor.color] as RGBAColor, + }); + segment.visible = descriptor.visible; + segment.locked = descriptor.locked ?? false; + segment.representations.labelmap = { + artifactId, + labelValue: descriptor.value, + extent, + }; + }); + + return segmentsForArtifact(artifactId); } /** The single voxel-allocation point: no other operation creates storage. */ - function ensureLabelmapBinding(segmentationId: string, segmentId: string) { + function ensureLabelmapBinding( + segmentationId: string, + segmentId: string, + preferredArtifactId?: string + ) { const segment = getSegment(segmentationId, segmentId); if (segment.representations.labelmap) return segment.representations.labelmap; const segmentation = getSegmentation(segmentationId); const artifactId = + preferredArtifactId ?? listSegments(segmentation).find((other) => other.representations.labelmap) ?.representations.labelmap?.artifactId ?? - createArtifact(segmentation.parentImageId); + createArtifactForImage(segmentation.parentImageId); const used = new Set( bindingsForArtifact(artifactId).map((binding) => binding.labelValue) @@ -217,11 +331,10 @@ export const useSegmentationStore = defineStore('segmentation', () => { const binding = getSegment(segmentationId, segmentId).representations .labelmap; - removeFromArray(segmentation.order, segmentId); - delete segmentation.segments[segmentId]; + detachSegment(segmentation, segmentId); if (!binding) return; - artifactIndex[binding.artifactId].replaceLabelValue( + artifactIndex[binding.artifactId]?.replaceLabelValue( binding.labelValue, LABELMAP_BACKGROUND_VALUE ); @@ -232,22 +345,61 @@ export const useSegmentationStore = defineStore('segmentation', () => { const segmentation = segmentations[segmentationId]; if (!segmentation) return; - const artifactIds = new Set( - listSegments(segmentation) - .map((segment) => segment.representations.labelmap?.artifactId) - .filter((id): id is string => !!id) - ); - - delete byParentImage[segmentation.parentImageId]; + const { parentImageId } = segmentation; + delete byParentImage[parentImageId]; delete segmentations[segmentationId]; - artifactIds.forEach(releaseUnreferencedArtifact); + removeArtifactsForImage(parentImageId); } + function removeArtifactsForImage(parentImageId: string) { + [...artifactsForImage(parentImageId)].forEach(removeArtifact); + delete artifactOrderByParent[parentImageId]; + } + + // --- render sync --- // + + // The labelmap renderer colors by voxel value, so each artifact receives the + // value-keyed projection of the segments bound to it. + const labelmapSegmentsByArtifact = computed(() => { + const byArtifact: Record = {}; + Object.keys(artifactMeta).forEach((artifactId) => { + byArtifact[artifactId] = []; + }); + Object.values(segmentations).forEach((segmentation) => { + listSegments(segmentation).forEach((segment) => { + const binding = segment.representations.labelmap; + if (!binding || !byArtifact[binding.artifactId]) return; + byArtifact[binding.artifactId].push({ + value: binding.labelValue, + name: segment.name, + color: [...segment.color] as RGBAColor, + visible: segment.visible, + locked: segment.locked, + }); + }); + }); + return byArtifact; + }); + + watch( + labelmapSegmentsByArtifact, + (byArtifact) => { + Object.entries(byArtifact).forEach(([artifactId, segments]) => { + artifactIndex[artifactId]?.setSegments(segments); + }); + }, + { immediate: true } + ); + + // --- handle deletions --- // + onImageDeleted((deleted) => { deleted.forEach((parentImageId) => { + delete nextDefaultIndex[parentImageId]; const id = byParentImage[parentImageId]; if (id) removeSegmentation(id); + else removeArtifactsForImage(parentImageId); }); }); @@ -256,6 +408,8 @@ export const useSegmentationStore = defineStore('segmentation', () => { byParentImage, artifactIndex, artifactMeta, + artifactOrderByParent, + labelmapSegmentsByArtifact, getSegmentationForImage, ensureSegmentationForImage, getSegment, @@ -266,5 +420,15 @@ export const useSegmentationStore = defineStore('segmentation', () => { reorderSegments, deleteSegment, removeSegmentation, + artifactsForImage, + getSegmentationForArtifact, + segmentsForArtifact, + findSegmentByLabelValue, + registerArtifact, + createArtifactForImage, + updateArtifactMeta, + setArtifactSegments, + removeArtifact, + pickUniqueArtifactName, }; }); diff --git a/src/store/tools/fillHoles.ts b/src/store/tools/fillHoles.ts index d6c123cee..3a6742e5e 100644 --- a/src/store/tools/fillHoles.ts +++ b/src/store/tools/fillHoles.ts @@ -5,7 +5,7 @@ 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 { useSegmentationStore } from '@/src/store/segmentations'; import { getImageMetadata } from '@/src/composables/useCurrentImage'; import { getEffectiveView } from '@/src/core/views/effectiveView'; import { fillHolesWorker } from '@/src/core/tools/paint/fillHoles.worker'; @@ -58,7 +58,7 @@ export const useFillHolesStore = defineStore('fillHoles', () => { const viewStore = useViewStore(); const viewSliceStore = useViewSliceStore(); const paintStore = usePaintToolStore(); - const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); // 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. @@ -73,7 +73,7 @@ export const useFillHolesStore = defineStore('fillHoles', () => { if (!groupId) { throw new Error('No active segment group'); } - const metadata = segmentGroupStore.metadataByID[groupId]; + const metadata = segmentationStore.artifactMeta[groupId]; const parentMetadata = getImageMetadata(metadata.parentImage); const labelMapLpsOrientation = getLPSDirections(segImage.getDirection()); @@ -109,9 +109,10 @@ export const useFillHolesStore = defineStore('fillHoles', () => { // active segment, whose lock is already enforced before the process starts. const lockedLabels = selectedSegment ? undefined - : Object.values(metadata.segments.byValue) + : segmentationStore + .segmentsForArtifact(groupId) .filter((segment) => segment.locked) - .map((segment) => segment.value); + .map((segment) => segment.representations.labelmap!.labelValue); const worker = await getWorker(); return worker.fillHolesWorker({ diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 9abe2e976..0e500007e 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -12,6 +12,7 @@ import { computeEffectiveView } from '@/src/core/views/effectiveView'; import { worldPointToIndex } from '@/src/utils/imageSpace'; import { Tools } from './types'; import { useSegmentGroupStore } from '../segmentGroups'; +import { useSegmentationStore } from '../segmentations'; import useViewSliceStore from '../view-configs/slicing'; import { useViewStore } from '../views'; import { useViewCameraStore } from '../view-configs/camera'; @@ -69,6 +70,7 @@ export const usePaintToolStore = defineStore('paint', () => { } const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); // Delete-base cleanup: removing a dataset cascades away its segment groups. // `serialize` writes the raw `activeSegmentGroupID`, so null it the instant @@ -79,7 +81,7 @@ export const usePaintToolStore = defineStore('paint', () => { watch( () => activeSegmentGroupID.value != null && - !(activeSegmentGroupID.value in segmentGroupStore.metadataByID), + !(activeSegmentGroupID.value in segmentationStore.artifactMeta), (orphaned) => { if (orphaned) activeSegmentGroupID.value = null; }, @@ -153,7 +155,7 @@ export const usePaintToolStore = defineStore('paint', () => { // If current segment group belongs to this image, keep using it if ( activeSegmentGroupID.value && - segmentGroupStore.metadataByID[activeSegmentGroupID.value] + segmentationStore.artifactMeta[activeSegmentGroupID.value] ?.parentImage === imageID ) { return activeSegmentGroupID.value; @@ -195,10 +197,12 @@ export const usePaintToolStore = defineStore('paint', () => { if (!activeSegmentGroupID.value) throw new Error('Cannot set active segment without a labelmap'); - const { segments } = - segmentGroupStore.metadataByID[activeSegmentGroupID.value]; - - if (!(segValue in segments.byValue)) + if ( + !segmentationStore.findSegmentByLabelValue( + activeSegmentGroupID.value, + segValue + ) + ) throw new Error('Segment is not available for the active labelmap'); lastSegmentByGroup.value[activeSegmentGroupID.value] = segValue; @@ -225,12 +229,20 @@ export const usePaintToolStore = defineStore('paint', () => { const labelmap = segmentGroupStore.dataIndex[segmentGroupID]; if (!labelmap) return; + // One catalog read per stroke: the per-voxel predicate below is the hot path. + const lockedValues = new Set( + segmentationStore + .segmentsForArtifact(segmentGroupID) + .filter((segment) => segment.locked) + .map((segment) => segment.representations.labelmap!.labelValue) + ); + // Prevent painting if active segment is locked or doesn't exist if (activeSegment.value) { - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (!metadata) return; - - const segment = metadata.segments.byValue[activeSegment.value]; + const segment = segmentationStore.findSegmentByLabelValue( + segmentGroupID, + activeSegment.value + ); if (!segment || segment.locked) { return; } @@ -246,17 +258,12 @@ export const usePaintToolStore = defineStore('paint', () => { 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 currentData = labelmap + .getPointData() + .getScalars() + .getData() as Uint8Array; + if (lockedValues.has(currentData[idx])) { + return false; } const pixValue = underlyingImagePixels[idx]; @@ -309,17 +316,20 @@ export const usePaintToolStore = defineStore('paint', () => { setActiveSegmentGroup(segmentGroupID); - const metadata = segmentGroupStore.metadataByID[segmentGroupID]; - if (!metadata) return; + if (!segmentationStore.artifactMeta[segmentGroupID]) return; + + const labelValues = segmentationStore + .segmentsForArtifact(segmentGroupID) + .map((segment) => segment.representations.labelmap!.labelValue); const lastSegment = lastSegmentByGroup.value[segmentGroupID]; - if (lastSegment !== undefined && lastSegment in metadata.segments.byValue) { + if (lastSegment !== undefined && labelValues.includes(lastSegment)) { setActiveSegment.call(this, lastSegment); return; } - if (metadata.segments.order.length > 0) { - setActiveSegment.call(this, metadata.segments.order[0]); + if (labelValues.length > 0) { + setActiveSegment.call(this, labelValues[0]); } } diff --git a/src/store/tools/paintProcess.ts b/src/store/tools/paintProcess.ts index 8220d4615..3167e7b78 100644 --- a/src/store/tools/paintProcess.ts +++ b/src/store/tools/paintProcess.ts @@ -7,6 +7,7 @@ import { PaintMode } from '@/src/core/tools/paint'; import { useMessageStore } from '@/src/store/messages'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { useSegmentGroupStore } from '../segmentGroups'; +import { useSegmentationStore } from '../segmentations'; export enum ProcessType { FillHoles = 'fillHoles', @@ -76,6 +77,7 @@ export const usePaintProcessStore = defineStore('paintProcess', () => { } const segmentGroupStore = useSegmentGroupStore(); + const segmentationStore = useSegmentationStore(); const paintStore = usePaintToolStore(); const { activeSegmentGroupID } = storeToRefs(paintStore); const messageStore = useMessageStore(); @@ -122,7 +124,10 @@ export const usePaintProcessStore = defineStore('paintProcess', () => { } // Check if the active segment is locked - const segment = segmentGroupStore.getSegment(groupId, activeSegment); + const segment = segmentationStore.findSegmentByLabelValue( + groupId, + activeSegment + ); if (segment?.locked) { messageStore.addError('Cannot process locked segment'); return; @@ -131,7 +136,7 @@ export const usePaintProcessStore = defineStore('paintProcess', () => { const segImage = segmentGroupStore.dataIndex[groupId]; const activeParentImageID = - segmentGroupStore.metadataByID[groupId].parentImage; + segmentationStore.artifactMeta[groupId].parentImage; const processType = activeProcessType.value; const processRunId = ++activeProcessRunId; 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/types/segmentation.ts b/src/types/segmentation.ts index 284b1c4ef..2cb0302f8 100644 --- a/src/types/segmentation.ts +++ b/src/types/segmentation.ts @@ -23,6 +23,19 @@ export type Segment = { }; }; +/** + * One artifact's label descriptor, derived from the segments bound to it. + * 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; +}; + export type Segmentation = { id: string; name: string; diff --git a/src/utils/bugReport.ts b/src/utils/bugReport.ts index 59181ba62..38b357b32 100644 --- a/src/utils/bugReport.ts +++ b/src/utils/bugReport.ts @@ -4,6 +4,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/store/segmentations'; import { COMPOUND_EXTENSIONS } from '@/src/utils/path'; const MAX_ERROR_LENGTH = 4000; @@ -57,7 +58,7 @@ const collectDatasetInfo = (): string[] => { ? 'DICOM' : 'unknown'; - const segCount = segmentGroupStore.orderByParent[id]?.length ?? 0; + const segCount = useSegmentationStore().artifactsForImage(id).length; const segPart = segCount > 0 ? ` (segment groups: ${segCount} as ${segmentGroupStore.saveFormat})` diff --git a/src/vtk/LabelMap/index.d.ts b/src/vtk/LabelMap/index.d.ts index df8c3c378..1d8ef26e9 100644 --- a/src/vtk/LabelMap/index.d.ts +++ b/src/vtk/LabelMap/index.d.ts @@ -1,4 +1,4 @@ -import { SegmentMask } from '@/src/types/segment'; +import { LabelmapSegment } from '@/src/types/segmentation'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import type { Vector4 } from '@kitware/vtk.js/types'; @@ -7,12 +7,12 @@ export interface vtkLabelMap extends vtkImageData { * Sets the segments of the labelmap. * @param segments */ - setSegments(segments: SegmentMask[]): boolean; + setSegments(segments: LabelmapSegment[]): boolean; /** * Gets the segments of the labelmap. */ - getSegments(): SegmentMask[]; + getSegments(): LabelmapSegment[]; /** * Replaces a labelmap value with another value. From f8229d7d7b00a7aa94e7b11ae34745d5111008b5 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 1 Sep 2026 00:30:01 -0400 Subject: [PATCH 005/221] refactor(segmentation): inject shared segment registry into annotation tools --- .../__tests__/labelShortcuts.spec.ts | 56 +++ src/composables/actions.ts | 3 + src/io/import/__tests__/configJson.spec.ts | 143 ++++++- src/processing/applyResults.ts | 15 +- src/store/__tests__/rulers.spec.ts | 134 +++++++ .../__tests__/annotationToolSegments.spec.ts | 176 +++++++++ .../tools/__tests__/segmentRegistry.spec.ts | 336 ++++++++++++++++ src/store/tools/polygons.ts | 4 +- src/store/tools/rectangles.ts | 5 +- src/store/tools/rulers.ts | 3 +- src/store/tools/segmentRegistry.ts | 360 ++++++++++++++++++ src/store/tools/useAnnotationTool.ts | 62 ++- src/types/segmentation.ts | 5 +- 13 files changed, 1253 insertions(+), 49 deletions(-) create mode 100644 src/composables/__tests__/labelShortcuts.spec.ts create mode 100644 src/store/tools/__tests__/annotationToolSegments.spec.ts create mode 100644 src/store/tools/__tests__/segmentRegistry.spec.ts create mode 100644 src/store/tools/segmentRegistry.ts diff --git a/src/composables/__tests__/labelShortcuts.spec.ts b/src/composables/__tests__/labelShortcuts.spec.ts new file mode 100644 index 000000000..c587862c7 --- /dev/null +++ b/src/composables/__tests__/labelShortcuts.spec.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { ACTION_TO_FUNC } from '@/src/composables/actions'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useToolStore } from '@/src/store/tools'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { Tools } from '@/src/store/tools/types'; +import { useViewStore } from '@/src/store/views'; + +const seatAndView = (id: string) => { + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + useViewStore().setDataForAllViews(id); +}; + +describe('next/previous label shortcuts', () => { + beforeEach(() => { + setActivePinia(createPinia()); + useToolStore().setCurrentTool(Tools.Polygon); + }); + + // Shared-registry tools have no segments until one is created. + it('is a no-op when the active tool has no labels', () => { + seatAndView('img-1'); + + expect(usePolygonStore().labels).toEqual({}); + expect(() => ACTION_TO_FUNC.incrementLabel()).not.toThrow(); + expect(() => ACTION_TO_FUNC.decrementLabel()).not.toThrow(); + expect(usePolygonStore().activeLabel).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 labels the active tool has', () => { + seatAndView('img-1'); + const store = usePolygonStore(); + const first = store.createSegment({ name: 'Tumor' }); + const second = store.createSegment({ name: 'Node' }); + + store.setActiveLabel(first); + ACTION_TO_FUNC.incrementLabel(); + expect(store.activeLabel).toBe(second); + + ACTION_TO_FUNC.incrementLabel(); + expect(store.activeLabel).toBe(first); + + ACTION_TO_FUNC.decrementLabel(); + expect(store.activeLabel).toBe(second); + }); +}); diff --git a/src/composables/actions.ts b/src/composables/actions.ts index 1da140678..1a9a1b061 100644 --- a/src/composables/actions.ts +++ b/src/composables/actions.ts @@ -27,6 +27,9 @@ const applyLabelOffset = (offset: number) => () => { if (!activeToolStore) return; const labels = Object.entries(activeToolStore.labels); + // Shared-registry tools start with no segments, so there is nothing to cycle. + if (labels.length === 0) return; + const activeLabelIndex = labels.findIndex( ([name]) => name === activeToolStore.activeLabel ); diff --git a/src/io/import/__tests__/configJson.spec.ts b/src/io/import/__tests__/configJson.spec.ts index ac2ec03c8..995b70c45 100644 --- a/src/io/import/__tests__/configJson.spec.ts +++ b/src/io/import/__tests__/configJson.spec.ts @@ -1,5 +1,17 @@ -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 } from '@/src/io/import/configJson'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/store/segmentations'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useViewStore } from '@/src/store/views'; + +type LabelRecord = { labelName?: string; color?: string; fillColor?: string }; describe('config schema', () => { describe('shortcuts', () => { @@ -69,3 +81,130 @@ describe('config schema', () => { }); }); }); + +describe('label config', () => { + const seatAndView = (id: string) => { + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + useViewStore().setDataForAllViews(id); + }; + + const labelSummary = (store: { labels: Record }) => + Object.values(store.labels).map(({ labelName, color }) => ({ + labelName, + color, + })); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + // Config is applied before the primary selection, so there is no current + // image when polygon and rectangle labels arrive. + it('applies polygon labels configured before an image loads', async () => { + applyPostStateConfig( + config.parse({ + labels: { polygonLabels: { Tumor: { color: '#00ff00' } } }, + }) + ); + + seatAndView('img-1'); + await nextTick(); + + expect(labelSummary(usePolygonStore())).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + }); + + it('applies rectangle labels configured before an image loads', async () => { + applyPostStateConfig( + config.parse({ + labels: { + rectangleLabels: { + Tumor: { color: '#00ff00', fillColor: '#00ff0033' }, + }, + }, + }) + ); + + seatAndView('img-1'); + await nextTick(); + + const store = useRectangleStore(); + expect(labelSummary(store)).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + expect(Object.values(store.labels).map((label) => label.fillColor)).toEqual( + ['#00ff0033'] + ); + }); + + it('falls back to defaultLabels for every tool kind', async () => { + applyPostStateConfig( + config.parse({ + labels: { defaultLabels: { Tumor: { color: '#00ff00' } } }, + }) + ); + + seatAndView('img-1'); + await nextTick(); + + expect(labelSummary(usePolygonStore())).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + expect(labelSummary(useRectangleStore())).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + expect(labelSummary(useRulerStore())).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + }); + + it('applies config labels to an image that is already loaded', async () => { + seatAndView('img-1'); + await nextTick(); + + applyPostStateConfig( + config.parse({ + labels: { polygonLabels: { Tumor: { color: '#00ff00' } } }, + }) + ); + + expect(labelSummary(usePolygonStore())).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + }); + + it('applies config labels to each image the user views', async () => { + applyPostStateConfig( + config.parse({ + labels: { polygonLabels: { Tumor: { color: '#00ff00' } } }, + }) + ); + + seatAndView('img-1'); + await nextTick(); + seatAndView('img-2'); + await nextTick(); + + expect(labelSummary(usePolygonStore())).toEqual([ + { labelName: 'Tumor', color: '#00ff00' }, + ]); + expect( + useSegmentationStore().getSegmentationForImage('img-1')?.order + ).toHaveLength(1); + }); + + it('creates no segments when no labels are configured', async () => { + applyPostStateConfig(config.parse({ labels: {} })); + + seatAndView('img-1'); + await nextTick(); + + expect(usePolygonStore().labels).toEqual({}); + expect( + useSegmentationStore().getSegmentationForImage('img-1') + ).toBeUndefined(); + }); +}); diff --git a/src/processing/applyResults.ts b/src/processing/applyResults.ts index f0681523f..d2b0631da 100644 --- a/src/processing/applyResults.ts +++ b/src/processing/applyResults.ts @@ -266,7 +266,8 @@ const prepareAnnotations = ( const mergeReferencedLabels = ( kind: AnnotationToolKind, tools: readonly PreparedCore[], - namespace: Record + namespace: Record, + imageId: string ): Record => { const store = annotationToolStore(kind); const names = new Set( @@ -278,7 +279,10 @@ const mergeReferencedLabels = ( const ids = Object.fromEntries( [...names].map((labelName) => [ labelName, - store.mergeLabel({ labelName, ...(namespace[labelName] ?? {}) }), + store.mergeLabelForImage(imageId, { + labelName, + ...(namespace[labelName] ?? {}), + }), ]) ); store.setActiveLabel(activeBefore); @@ -342,7 +346,12 @@ async function applyAnnotations( const labelIds = Object.fromEntries( ANNOTATION_TOOL_KINDS.map((kind) => [ kind, - mergeReferencedLabels(kind, prepared[kind], decoded.labels[kind]), + mergeReferencedLabels( + kind, + prepared[kind], + decoded.labels[kind], + parentSelection + ), ]) ) as Record>; diff --git a/src/store/__tests__/rulers.spec.ts b/src/store/__tests__/rulers.spec.ts index f47d88284..9fe0c2732 100644 --- a/src/store/__tests__/rulers.spec.ts +++ b/src/store/__tests__/rulers.spec.ts @@ -1,6 +1,13 @@ import { describe, it, beforeEach, expect } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; +import { nextTick } from 'vue'; +import { + RULER_LABEL_DEFAULTS, + STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + TOOL_COLORS, +} from '@/src/config'; +import { useSegmentationStore } from '@/src/store/segmentations'; import { useRulerStore } from '@/src/store/tools/rulers'; import { Ruler } from '@/src/types/ruler'; import { RequiredWithPartial } from '@/src/types'; @@ -84,3 +91,130 @@ describe('Ruler store', () => { // TODO testing jumpToRuler requires store integration // TODO testing (de)serialize requires store integration }); + +describe('Ruler store labels', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + const activeLabelID = (store: ReturnType) => + store.activeLabel as string; + + it('seeds and activates the configured default labels', () => { + const store = useRulerStore(); + + expect(Object.values(store.labels).map((label) => label.labelName)).toEqual( + Object.keys(RULER_LABEL_DEFAULTS) + ); + expect(store.labels[activeLabelID(store)]).toMatchObject({ + labelName: 'Label 1', + color: 'red', + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + }); + }); + + it('adds a ruler carrying the active label', () => { + const store = useRulerStore(); + const label = activeLabelID(store); + + const id = store.addRuler({ ...createRuler(), label }); + + expect(store.rulerByID[id]).toMatchObject({ + label, + labelName: 'Label 1', + color: 'red', + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, + }); + }); + + it('defaults a new ruler to the active label', () => { + const store = useRulerStore(); + const id = store.addRuler(createRuler()); + + expect(store.rulerByID[id].label).toBe(store.activeLabel); + }); + + it('continues the tool color cycle past the seeded labels', () => { + const store = useRulerStore(); + + const id = store.addLabel({ labelName: 'Tumor' }); + + expect(store.labels[id].color).toBe(TOOL_COLORS[1]); + expect(store.activeLabel).toBe(id); + }); + + it('propagates a label rename to existing rulers', async () => { + const store = useRulerStore(); + const label = activeLabelID(store); + const id = store.addRuler({ ...createRuler(), label }); + + store.updateLabel(label, { labelName: 'Lesion', color: 'blue' }); + await nextTick(); + + expect(store.rulerByID[id]).toMatchObject({ + labelName: 'Lesion', + color: 'blue', + }); + }); + + it('clears the label name of rulers whose label was deleted', async () => { + const store = useRulerStore(); + const label = activeLabelID(store); + const id = store.addRuler({ ...createRuler(), label }); + + store.deleteLabel(label); + await nextTick(); + + expect(store.rulerByID[id].labelName).toBe(''); + }); + + it('merges labels by name and reports them through findLabel', () => { + const store = useRulerStore(); + + store.mergeLabels({ 'Label 1': { color: 'green' } }); + + expect(Object.keys(store.labels)).toHaveLength(1); + expect(store.findLabel('Label 1')?.[1]).toMatchObject({ color: 'green' }); + }); + + it('drops the seeded labels on clearDefaultLabels', () => { + const store = useRulerStore(); + + store.clearDefaultLabels(); + + expect(store.labels).toEqual({}); + }); + + it('sets the active label, including back to unset', () => { + const store = useRulerStore(); + const label = activeLabelID(store); + + store.setActiveLabel(undefined); + expect(store.activeLabel).toBeUndefined(); + + store.setActiveLabel(label); + expect(store.activeLabel).toBe(label); + }); + + it('serializes its own labels', () => { + const store = useRulerStore(); + const label = activeLabelID(store); + store.addRuler({ ...createRuler(), label }); + + const { labels, tools } = store.serializeTools(); + + expect(labels).toEqual(store.labels); + expect(tools[0].label).toBe(label); + }); + + it('keeps ruler labels out of the segmentation store', () => { + const store = useRulerStore(); + const segmentation = useSegmentationStore().ensureSegmentationForImage('4'); + + useSegmentationStore().createSegment(segmentation.id, { name: 'Tumor' }); + + expect(Object.values(store.labels).map((label) => label.labelName)).toEqual( + ['Label 1'] + ); + }); +}); diff --git a/src/store/tools/__tests__/annotationToolSegments.spec.ts b/src/store/tools/__tests__/annotationToolSegments.spec.ts new file mode 100644 index 000000000..bd79a6e99 --- /dev/null +++ b/src/store/tools/__tests__/annotationToolSegments.spec.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { nextTick } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/store/segmentations'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { useViewStore } from '@/src/store/views'; +import { rgbaToCssColor } from '@/src/types/segmentation'; + +const IMAGE_ID = 'img-1'; + +const seatAndView = (id: string) => { + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { + id, + }); + useViewStore().setDataForAllViews(id); + return useSegmentationStore().ensureSegmentationForImage(id); +}; + +const makeSegment = (name: string, color?: [number, number, number, number]) => + useSegmentationStore().createSegment( + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!.id, + { name, ...(color ? { color } : {}) } + ); + +describe('shared segment identity for polygons and rectangles', () => { + beforeEach(() => { + setActivePinia(createPinia()); + seatAndView(IMAGE_ID); + }); + + it('lists the viewed image’s segments', () => { + const store = usePolygonStore(); + const segment = makeSegment('Tumor'); + + expect(store.segments.map((entry) => entry.id)).toEqual([segment.id]); + expect(store.segments.map((entry) => entry.name)).toEqual(['Tumor']); + }); + + it('captures the active segment id when a tool is added', () => { + const store = usePolygonStore(); + const segment = makeSegment('Tumor'); + store.setActiveSegment(segment.id); + + const id = store.addTool({ imageID: IMAGE_ID, placing: false }); + + expect(store.activeSegmentId).toBe(segment.id); + expect(store.toolByID[id].label).toBe(segment.id); + }); + + it('shows the segment name and color on the tool', () => { + const store = usePolygonStore(); + const segment = makeSegment('Tumor', [214, 0, 0, 255]); + store.setActiveSegment(segment.id); + + const id = store.addTool({ + imageID: IMAGE_ID, + placing: false, + label: segment.id, + }); + + expect(store.toolByID[id].labelName).toBe('Tumor'); + expect(store.toolByID[id].color).toBe(rgbaToCssColor([214, 0, 0, 255])); + }); + + it('updates a tool’s displayed name when the segment is renamed', async () => { + const store = usePolygonStore(); + const segmentation = + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!; + const segment = makeSegment('Tumor'); + store.setActiveSegment(segment.id); + const id = store.addTool({ + imageID: IMAGE_ID, + placing: false, + label: segment.id, + }); + + useSegmentationStore().updateSegment(segmentation.id, segment.id, { + name: 'Lesion', + }); + await nextTick(); + + expect(store.toolByID[id].labelName).toBe('Lesion'); + }); + + it('updates a tool’s color when the segment is recolored', async () => { + const store = usePolygonStore(); + const segmentation = + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!; + const segment = makeSegment('Tumor', [214, 0, 0, 255]); + store.setActiveSegment(segment.id); + const id = store.addTool({ + imageID: IMAGE_ID, + placing: false, + label: segment.id, + }); + + useSegmentationStore().updateSegment(segmentation.id, segment.id, { + color: [0, 0, 255, 255], + }); + await nextTick(); + + expect(store.toolByID[id].color).toBe(rgbaToCssColor([0, 0, 255, 255])); + }); + + it('shares one segment catalog between polygons and rectangles', () => { + const polygons = usePolygonStore(); + const rectangles = useRectangleStore(); + const segment = makeSegment('Tumor'); + + expect(polygons.segments.map((entry) => entry.id)).toEqual([segment.id]); + expect(rectangles.segments.map((entry) => entry.id)).toEqual([segment.id]); + }); + + it('keeps per-tool props out of the shared segment', () => { + const rectangles = useRectangleStore(); + const segment = makeSegment('Tumor'); + rectangles.setActiveSegment(segment.id); + + const id = rectangles.addTool({ + imageID: IMAGE_ID, + placing: false, + label: segment.id, + }); + + expect(rectangles.toolByID[id].fillColor).toBe('transparent'); + expect(segment).not.toHaveProperty('fillColor'); + }); + + it('creates a segment in the segmentation store through the tool store', () => { + const store = usePolygonStore(); + + const id = store.createSegment({ name: 'Tumor' }); + + const segmentation = + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!; + expect(segmentation.order).toEqual([id]); + expect(segmentation.segments[id].name).toBe('Tumor'); + }); + + it('restores a tool whose segment was deleted as unlabeled', () => { + const store = usePolygonStore(); + const segmentation = + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!; + const segment = makeSegment('Tumor'); + store.setActiveSegment(segment.id); + store.addTool({ imageID: IMAGE_ID, placing: false, label: segment.id }); + useSegmentationStore().deleteSegment(segmentation.id, segment.id); + + const serialized = JSON.parse(JSON.stringify(store.serializeTools())); + expect(serialized.tools[0].label).toBe(segment.id); + expect(serialized.labels).toEqual({}); + + setActivePinia(createPinia()); + seatAndView(IMAGE_ID); + const restored = usePolygonStore(); + restored.deserializeTools(serialized, { [IMAGE_ID]: IMAGE_ID }); + + expect(restored.toolByID[restored.toolIDs[0]].label).toBe(''); + expect( + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!.order + ).toEqual([]); + }); + + it('pre-creates no segments for an empty segmentation', () => { + const store = usePolygonStore(); + + expect(store.segments).toEqual([]); + expect( + useSegmentationStore().getSegmentationForImage(IMAGE_ID)!.order + ).toEqual([]); + }); +}); diff --git a/src/store/tools/__tests__/segmentRegistry.spec.ts b/src/store/tools/__tests__/segmentRegistry.spec.ts new file mode 100644 index 000000000..e5ea0ff06 --- /dev/null +++ b/src/store/tools/__tests__/segmentRegistry.spec.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { RULER_LABEL_DEFAULTS, TOOL_COLORS } from '@/src/config'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/store/segmentations'; +import { useViewStore } from '@/src/store/views'; +import { + createLocalSegmentRegistry, + createSharedSegmentRegistry, +} from '@/src/store/tools/segmentRegistry'; +import { rgbaToCssColor } from '@/src/types/segmentation'; + +const seatImage = (id: string, name = 'CT') => + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), name, { + id, + }); + +const viewImage = (id: string) => useViewStore().setDataForAllViews(id); + +const segmentationStore = () => useSegmentationStore(); + +/** Seats an image, points every view at it, and returns its segmentation. */ +const seatAndView = (id: string) => { + seatImage(id); + viewImage(id); + return segmentationStore().ensureSegmentationForImage(id); +}; + +describe('shared segment registry', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('lists the viewed image’s segments in segmentation order', () => { + const segmentation = seatAndView('img-1'); + const first = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + }); + const second = segmentationStore().createSegment(segmentation.id, { + name: 'Node', + }); + + const registry = createSharedSegmentRegistry(); + + expect(registry.segments.value.map((segment) => segment.id)).toEqual([ + first.id, + second.id, + ]); + expect(registry.segments.value.map((segment) => segment.name)).toEqual([ + 'Tumor', + 'Node', + ]); + }); + + it('reports no segments for an image without a segmentation', () => { + seatImage('img-1'); + viewImage('img-1'); + + expect(createSharedSegmentRegistry().segments.value).toEqual([]); + }); + + it('exposes segment colors as css strings for tool rendering', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + color: [214, 0, 0, 255], + }); + + const registry = createSharedSegmentRegistry(); + + expect(registry.getSegment(segment.id)?.color).toBe( + rgbaToCssColor([214, 0, 0, 255]) + ); + }); + + it('reflects a rename made through the segmentation store', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + }); + const registry = createSharedSegmentRegistry(); + + segmentationStore().updateSegment(segmentation.id, segment.id, { + name: 'Lesion', + }); + + expect(registry.getSegment(segment.id)?.name).toBe('Lesion'); + expect(registry.segments.value.map((entry) => entry.name)).toEqual([ + 'Lesion', + ]); + }); + + it('reflects a recolor made through the segmentation store', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + color: [214, 0, 0, 255], + }); + const registry = createSharedSegmentRegistry(); + + segmentationStore().updateSegment(segmentation.id, segment.id, { + color: [0, 0, 255, 255], + }); + + expect(registry.getSegment(segment.id)?.color).toBe( + rgbaToCssColor([0, 0, 255, 255]) + ); + }); + + it('drops a segment deleted through the segmentation store', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + }); + const registry = createSharedSegmentRegistry(); + + segmentationStore().deleteSegment(segmentation.id, segment.id); + + expect(registry.segments.value).toEqual([]); + expect(registry.getSegment(segment.id)).toBeUndefined(); + }); + + it('returns undefined for an unknown segment id', () => { + seatAndView('img-1'); + + expect(createSharedSegmentRegistry().getSegment('nope')).toBeUndefined(); + }); + + it('creates segments in the viewed image’s segmentation', () => { + const segmentation = seatAndView('img-1'); + const registry = createSharedSegmentRegistry(); + + const id = registry.createSegment({ name: 'Tumor', color: '#00ff00ff' }); + + expect(segmentation.order).toEqual([id]); + expect(segmentation.segments[id].name).toBe('Tumor'); + expect(segmentation.segments[id].color).toEqual([0, 255, 0, 255]); + }); + + it('seeds a segmentation when the viewed image has none', () => { + seatImage('img-1'); + viewImage('img-1'); + const registry = createSharedSegmentRegistry(); + + const id = registry.createSegment(); + + expect(segmentationStore().getSegmentationForImage('img-1')?.order).toEqual( + [id] + ); + }); + + it('allocates no voxels when creating a segment', () => { + const segmentation = seatAndView('img-1'); + const registry = createSharedSegmentRegistry(); + + const id = registry.createSegment({ name: 'Tumor' }); + + expect(segmentation.segments[id].representations.labelmap).toBeUndefined(); + expect(segmentationStore().artifactsForImage('img-1')).toEqual([]); + }); + + it('tracks the active segment', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + }); + const registry = createSharedSegmentRegistry(); + + registry.setActiveSegment(segment.id); + expect(registry.activeSegmentId.value).toBe(segment.id); + + registry.setActiveSegment(undefined); + expect(registry.activeSegmentId.value).toBeFalsy(); + }); + + it('scopes segments to the viewed image', () => { + const first = seatAndView('img-1'); + segmentationStore().createSegment(first.id, { name: 'Tumor' }); + const second = seatAndView('img-2'); + const other = segmentationStore().createSegment(second.id, { + name: 'Node', + }); + const registry = createSharedSegmentRegistry(); + + expect(registry.segments.value.map((segment) => segment.id)).toEqual([ + other.id, + ]); + + viewImage('img-1'); + + expect(registry.segments.value.map((segment) => segment.name)).toEqual([ + 'Tumor', + ]); + }); + + it('shares one segment catalog across registries on the same image', () => { + seatAndView('img-1'); + const polygons = createSharedSegmentRegistry(); + const rectangles = createSharedSegmentRegistry(); + + const id = polygons.createSegment({ name: 'Tumor' }); + + expect(rectangles.getSegment(id)?.name).toBe('Tumor'); + }); +}); + +describe('local segment registry', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('seeds the initial labels by name and color', () => { + const registry = createLocalSegmentRegistry(RULER_LABEL_DEFAULTS); + + expect(registry.segments.value.map((segment) => segment.name)).toEqual([ + 'Label 1', + ]); + expect(registry.segments.value.map((segment) => segment.color)).toEqual([ + 'red', + ]); + }); + + it('activates the segment it creates', () => { + const registry = createLocalSegmentRegistry({}); + + const id = registry.createSegment({ name: 'Tumor' }); + + expect(registry.activeSegmentId.value).toBe(id); + expect(registry.getSegment(id)?.name).toBe('Tumor'); + }); + + it('cycles the tool colors for created segments', () => { + const registry = createLocalSegmentRegistry({}); + + const ids = TOOL_COLORS.map(() => registry.createSegment()); + + expect(ids.map((id) => registry.getSegment(id)?.color)).toEqual([ + ...TOOL_COLORS, + ]); + }); + + it('honors an explicit color', () => { + const registry = createLocalSegmentRegistry({}); + + const id = registry.createSegment({ name: 'Tumor', color: 'red' }); + + expect(registry.getSegment(id)?.color).toBe('red'); + }); + + it('applies the new label defaults to created segments', () => { + const registry = createLocalSegmentRegistry({}, { strokeWidth: 3 }); + + const id = registry.createSegment(); + + expect(registry.labels.value[id].strokeWidth).toBe(3); + }); + + it('renames through the label api', () => { + const registry = createLocalSegmentRegistry({}); + const id = registry.createSegment({ name: 'Tumor' }); + + registry.updateLabel(id, { labelName: 'Lesion' }); + + expect(registry.getSegment(id)?.name).toBe('Lesion'); + }); + + it('recolors through the label api', () => { + const registry = createLocalSegmentRegistry({}); + const id = registry.createSegment({ name: 'Tumor' }); + + registry.updateLabel(id, { color: 'red' }); + + expect(registry.getSegment(id)?.color).toBe('red'); + }); + + it('moves the active segment when the active one is deleted', () => { + const registry = createLocalSegmentRegistry({}); + const kept = registry.createSegment({ name: 'Tumor' }); + const doomed = registry.createSegment({ name: 'Node' }); + + registry.deleteLabel(doomed); + + expect(registry.segments.value.map((segment) => segment.id)).toEqual([ + kept, + ]); + expect(registry.activeSegmentId.value).toBe(kept); + }); + + it('clears the active segment when the last one is deleted', () => { + const registry = createLocalSegmentRegistry({}); + const id = registry.createSegment({ name: 'Tumor' }); + + registry.deleteLabel(id); + + expect(registry.segments.value).toEqual([]); + expect(registry.activeSegmentId.value).toBeFalsy(); + }); + + it('rejects deleting an unknown label', () => { + const registry = createLocalSegmentRegistry({}); + + expect(() => registry.deleteLabel('nope')).toThrow(); + }); + + it('accepts an unset active segment', () => { + const registry = createLocalSegmentRegistry({}); + registry.createSegment({ name: 'Tumor' }); + + registry.setActiveSegment(undefined); + + expect(registry.activeSegmentId.value).toBeFalsy(); + }); + + it('keeps its segments out of the segmentation store', () => { + seatImage('img-1'); + viewImage('img-1'); + const registry = createLocalSegmentRegistry({}); + + registry.createSegment({ name: 'Tumor' }); + + expect( + segmentationStore().getSegmentationForImage('img-1') + ).toBeUndefined(); + }); + + it('does not see segments created in the segmentation store', () => { + const segmentation = seatAndView('img-1'); + const registry = createLocalSegmentRegistry({}); + + segmentationStore().createSegment(segmentation.id, { name: 'Tumor' }); + + expect(registry.segments.value).toEqual([]); + }); +}); diff --git a/src/store/tools/polygons.ts b/src/store/tools/polygons.ts index 410e3650b..d39125aa9 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 { createSharedSegmentRegistry } from './segmentRegistry'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -38,7 +38,7 @@ const ensureVec2 = (regions: (Vec2 | Vec6)[][]) => { export const usePolygonStore = defineAnnotationToolStore('polygon', () => { const toolAPI = useAnnotationTool({ toolDefaults, - initialLabels: POLYGON_LABEL_DEFAULTS, + segments: () => createSharedSegmentRegistry(), }); function getPoints(id: ToolID) { diff --git a/src/store/tools/rectangles.ts b/src/store/tools/rectangles.ts index 24d028e68..ae5f78da3 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 { createSharedSegmentRegistry } from './segmentRegistry'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -26,8 +26,7 @@ const newLabelDefault = { export const useRectangleStore = defineAnnotationToolStore('rectangles', () => { const toolAPI = useAnnotationTool({ toolDefaults: rectangleDefaults, - initialLabels: RECTANGLE_LABEL_DEFAULTS, - newLabelDefault, + segments: () => createSharedSegmentRegistry(newLabelDefault), }); function getPoints(id: ToolID) { diff --git a/src/store/tools/rulers.ts b/src/store/tools/rulers.ts index cf02ca1a6..9012664f4 100644 --- a/src/store/tools/rulers.ts +++ b/src/store/tools/rulers.ts @@ -7,6 +7,7 @@ import { ToolID } from '@/src/types/annotation-tool'; import { RULER_LABEL_DEFAULTS } from '@/src/config'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; +import { createLocalSegmentRegistry } from './segmentRegistry'; import { declareAnnotationToolManifestRefs, useAnnotationTool, @@ -24,7 +25,7 @@ const rulerDefaults = () => ({ export const useRulerStore = defineAnnotationToolStore('ruler', () => { const annotationTool = useAnnotationTool({ toolDefaults: rulerDefaults, - initialLabels: RULER_LABEL_DEFAULTS, + segments: () => createLocalSegmentRegistry(RULER_LABEL_DEFAULTS), }); // prefix some props with ruler diff --git a/src/store/tools/segmentRegistry.ts b/src/store/tools/segmentRegistry.ts new file mode 100644 index 000000000..1c975b977 --- /dev/null +++ b/src/store/tools/segmentRegistry.ts @@ -0,0 +1,360 @@ +import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'; + +import { STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT } from '@/src/config'; +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { useSegmentationStore } from '@/src/store/segmentations'; +import type { Maybe } from '@/src/types'; +import type { Segment, Segmentation } from '@/src/types/segmentation'; +import { cssColorToRGBA, rgbaToCssColor } from '@/src/types/segmentation'; +import { useLabels, type Label, type Labels } from './useLabels'; + +export type RegistrySegment = { + id: string; + name: string; + color: string; // CSS color for tool rendering +}; + +export type SegmentRegistry = { + segments: ComputedRef; + activeSegmentId: ComputedRef>; + getSegment: (id: string) => Maybe; + setActiveSegment: (id: Maybe) => void; + createSegment: (init?: { name?: string; color?: string }) => string; +}; + +/** + * The label-record surface label pickers, the config importer and the wire + * shims still read off a tool store. Identity in it is a projection of the + * registry's segments; only the per-tool props are the tool store's own. + */ +export type SegmentLabelApi = { + labels: Ref>; + // Every segment a tool may point at, including tools on other images. + allLabels: Ref>; + activeLabel: Ref; + setActiveLabel: (id: string | undefined) => void; + addLabel: (label?: Label) => string; + updateLabel: (id: string, patch: Label) => void; + deleteLabel: (id: string) => void; + mergeLabel: (label: Label) => string; + mergeLabels: (labels: Maybe>) => void; + findLabel: (name: Maybe) => [string, Label] | undefined; + clearDefaultLabels: () => void; + // wire-format shim, replaced in C7/C8 + mergeLabelForImage: (imageId: Maybe, label: Label) => string; + // wire-format shim, replaced in C7/C8 + adoptLabels: ( + labels: Labels + ) => (labelId: Maybe, imageId: Maybe) => string; + // wire-format shim, replaced in C7/C8 + serializeLabels: (referenced: string[]) => Labels; +}; + +export type ToolSegmentRegistry = SegmentRegistry & + SegmentLabelApi; + +const annotationToolLabelDefault = Object.freeze({ + strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT as number, +}); + +const listSegments = (segmentation: Segmentation) => + segmentation.order.map((id) => segmentation.segments[id]); + +const toRegistrySegment = (segment: Segment) => ({ + id: segment.id, + name: segment.name, + color: rgbaToCssColor(segment.color), +}); + +/** Identity from the segmentation store, scoped to the viewed image. */ +export const createSharedSegmentRegistry = ( + newLabelDefault?: Props +): ToolSegmentRegistry => { + const segmentationStore = useSegmentationStore(); + const { currentImageID } = useCurrentImage('global'); + + type ToolLabel = Label; + + // Per-tool props stay with the tool store; the segment carries identity only. + const propsBySegment = ref>({}) as Ref< + Record + >; + + // Temporary; C5 moves this onto the segmentation store's active target. + const activeLabel = ref(); + + const segmentationFor = (imageId: Maybe) => + imageId ? segmentationStore.getSegmentationForImage(imageId) : undefined; + + const currentSegmentation = computed(() => + segmentationFor(currentImageID.value) + ); + + const currentSegments = computed(() => { + const segmentation = currentSegmentation.value; + return segmentation ? listSegments(segmentation) : []; + }); + + const segments = computed(() => currentSegments.value.map(toRegistrySegment)); + + const owningSegmentation = (segmentId: string) => + Object.values(segmentationStore.segmentations).find( + (segmentation) => segmentId in segmentation.segments + ); + + const findSegment = (segmentId: string) => + owningSegmentation(segmentId)?.segments[segmentId]; + + const getSegment = (id: string) => { + const segment = findSegment(id); + return segment ? toRegistrySegment(segment) : undefined; + }; + + const toLabel = (segment: Segment) => + ({ + ...annotationToolLabelDefault, + ...newLabelDefault, + ...propsBySegment.value[segment.id], + labelName: segment.name, + color: rgbaToCssColor(segment.color), + }) as ToolLabel; + + const toLabelRecord = (list: Segment[]) => + Object.fromEntries( + list.map((segment) => [segment.id, toLabel(segment)]) + ) as Labels; + + const labels = computed(() => toLabelRecord(currentSegments.value)); + + const allLabels = computed(() => + toLabelRecord( + Object.values(segmentationStore.segmentations).flatMap(listSegments) + ) + ); + + const setActiveLabel = (id: string | undefined) => { + activeLabel.value = id; + }; + + const setProps = (segmentId: string, props: ToolLabel) => { + propsBySegment.value = { + ...propsBySegment.value, + [segmentId]: { ...propsBySegment.value[segmentId], ...props }, + }; + }; + + const splitLabel = (label: ToolLabel) => { + const { labelName, color, ...props } = label; + return { + identity: { + ...(labelName === undefined ? {} : { name: labelName }), + ...(color === undefined ? {} : { color: cssColorToRGBA(color) }), + }, + props: props as ToolLabel, + }; + }; + + const addLabelForImage = (imageId: Maybe, label: ToolLabel) => { + if (!imageId) return ''; + const segmentation = segmentationStore.ensureSegmentationForImage(imageId); + const { identity, props } = splitLabel(label); + const segment = segmentationStore.createSegment(segmentation.id, identity); + setProps(segment.id, props); + return segment.id; + }; + + const addLabel = (label: ToolLabel = {} as ToolLabel) => { + const id = addLabelForImage(currentImageID.value, label); + if (id) setActiveLabel(id); + return id; + }; + + const updateLabel = (id: string, patch: ToolLabel) => { + const segmentation = owningSegmentation(id); + if (!segmentation) throw new Error('Label does not exist'); + + const { identity, props } = splitLabel(patch); + segmentationStore.updateSegment(segmentation.id, id, identity); + setProps(id, props); + }; + + const deleteLabel = (id: string) => { + const segmentation = owningSegmentation(id); + if (!segmentation) throw new Error('Label does not exist'); + + segmentationStore.deleteSegment(segmentation.id, id); + propsBySegment.value = Object.fromEntries( + Object.entries(propsBySegment.value).filter(([key]) => key !== id) + ); + + if (id === activeLabel.value) { + setActiveLabel(segments.value[0]?.id ?? ''); + } + }; + + const findLabelForImage = (imageId: Maybe, name: Maybe) => { + const segmentation = segmentationFor(imageId); + if (!segmentation) return undefined; + const segment = listSegments(segmentation).find( + (candidate) => candidate.name === name + ); + return segment + ? ([segment.id, toLabel(segment)] as [string, ToolLabel]) + : undefined; + }; + + const findLabel = (name: Maybe) => + findLabelForImage(currentImageID.value, name); + + const mergeLabelForImage = (imageId: Maybe, label: ToolLabel) => { + const existing = findLabelForImage(imageId, label.labelName); + if (existing) { + updateLabel(existing[0], label); + return existing[0]; + } + const id = addLabelForImage(imageId, label); + if (id) setActiveLabel(id); + return id; + }; + + const mergeLabel = (label: ToolLabel) => + mergeLabelForImage(currentImageID.value, label); + + // Config labels are declared once for the session, but segments live per + // image and config lands before any image loads, so they are held here and + // seeded into each image's segmentation as that image becomes current. + let sessionLabels: Labels = {}; + const seededImages = new Set(); + + const seedSessionLabels = (imageId: string) => { + const entries = Object.entries(sessionLabels); + if (entries.length === 0 || seededImages.has(imageId)) return; + seededImages.add(imageId); + entries.forEach(([labelName, props]) => + mergeLabelForImage(imageId, { ...props, labelName } as ToolLabel) + ); + }; + + const mergeLabels = (newLabels: Maybe>) => { + const entries = Object.entries(newLabels ?? {}); + if (entries.length === 0) return; + + sessionLabels = { ...sessionLabels, ...Object.fromEntries(entries) }; + seededImages.clear(); + if (currentImageID.value) seedSessionLabels(currentImageID.value); + }; + + watch(currentImageID, (imageId) => { + if (imageId) seedSessionLabels(imageId); + }); + + // Segments are never seeded, so there is nothing default to clear. + const clearDefaultLabels = () => {}; + + const adoptLabels = (serialized: Labels) => { + const adopted = new Map(); + return (labelId: Maybe, imageId: Maybe) => { + if (!labelId || !imageId) return ''; + // A label the manifest never carried is a deleted one; restore unlabeled. + if (!(labelId in serialized)) return ''; + const key = `${labelId}|${imageId}`; + const existing = adopted.get(key); + if (existing !== undefined) return existing; + const id = addLabelForImage(imageId, serialized[labelId] as ToolLabel); + adopted.set(key, id); + return id; + }; + }; + + const serializeLabels = (referenced: string[]) => { + const all = allLabels.value; + return Object.fromEntries( + [...new Set(referenced)] + .filter((id) => id in all) + .map((id) => [id, all[id]]) + ) as Labels; + }; + + return { + segments, + activeSegmentId: computed(() => activeLabel.value), + getSegment, + setActiveSegment: (id: Maybe) => setActiveLabel(id ?? undefined), + createSegment: (init?: { name?: string; color?: string }) => + addLabel({ + ...(init?.name === undefined ? {} : { labelName: init.name }), + ...(init?.color === undefined ? {} : { color: init.color }), + } as ToolLabel), + labels, + allLabels, + activeLabel, + setActiveLabel, + addLabel, + updateLabel, + deleteLabel, + mergeLabel, + mergeLabels, + findLabel, + clearDefaultLabels, + mergeLabelForImage, + adoptLabels, + serializeLabels, + }; +}; + +/** Identity owned by the tool store itself, as rulers have always had it. */ +export const createLocalSegmentRegistry = ( + initialLabels: Labels, + newLabelDefault?: Props +): ToolSegmentRegistry => { + type ToolLabel = Label; + + const labels = useLabels({ + ...annotationToolLabelDefault, + ...newLabelDefault, + } as Props); + labels.mergeLabels(initialLabels); + + const toSegment = (id: string, label: ToolLabel) => ({ + id, + name: label.labelName ?? '', + color: label.color ?? '', + }); + + const getSegment = (id: string) => { + const label = labels.labels.value[id]; + return label ? toSegment(id, label) : undefined; + }; + + return { + segments: computed(() => + Object.entries(labels.labels.value).map(([id, label]) => + toSegment(id, label) + ) + ), + activeSegmentId: computed(() => labels.activeLabel.value), + getSegment, + setActiveSegment: (id: Maybe) => + labels.setActiveLabel(id ?? undefined), + createSegment: (init?: { name?: string; color?: string }) => + labels.addLabel({ + ...(init?.name === undefined ? {} : { labelName: init.name }), + ...(init?.color === undefined ? {} : { color: init.color }), + } as ToolLabel), + ...labels, + allLabels: labels.labels, + mergeLabelForImage: (_imageId: Maybe, label: ToolLabel) => + labels.mergeLabel(label), + adoptLabels: (serialized: Labels) => { + labels.clearDefaultLabels(); + const idMap = Object.fromEntries( + Object.entries(serialized).map(([id, label]) => [ + id, + labels.addLabel(label as ToolLabel), // side effect in Array.map + ]) + ); + return (labelId: Maybe) => (labelId && idMap[labelId]) || ''; + }, + serializeLabels: () => labels.labels.value, + }; +}; diff --git a/src/store/tools/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index d9a348913..587796ad6 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -14,7 +14,8 @@ 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 { ToolSegmentRegistry } from './segmentRegistry'; +import type { Labels } from './useLabels'; // Shared manifest-ref declaration for the annotation-tool stores. Each store // calls this at module scope next to its serialize, pairing the dev-backstop @@ -39,10 +40,6 @@ export const declareAnnotationToolManifestRefs = ( ); }); -const annotationToolLabelDefault = Object.freeze({ - strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT as number, -}); - const makeAnnotationToolDefaults = () => ({ frameOfReference: { planeOrigin: [0, 0, 0], @@ -62,12 +59,11 @@ export const useAnnotationTool = < LabelProps, >({ toolDefaults, - initialLabels, - newLabelDefault, + segments, }: { toolDefaults: MakeToolDefaults; - initialLabels: Labels; - newLabelDefault?: LabelProps; + // Factory, not the invoked registry: tools are created inside store setup. + segments: () => ToolSegmentRegistry; }) => { type ToolDefaults = ReturnType; type Tool = ToolDefaults & AnnotationTool; @@ -88,16 +84,12 @@ export const useAnnotationTool = < tools.value.filter((tool): tool is FinishedTool => !tool.placing) ); - const labels = useLabels({ - ...annotationToolLabelDefault, - ...newLabelDefault, - }); - labels.mergeLabels(initialLabels); + const registry = segments(); - function makePropsFromLabel(label: string | undefined) { + function makePropsFromLabel(label: Maybe) { if (!label) return { labelName: '' }; - const labelProps = labels.labels.value[label]; + const labelProps = registry.allLabels.value[label]; if (labelProps) return labelProps; // if label deleted, remove label name from tool @@ -113,7 +105,7 @@ export const useAnnotationTool = < toolByID.value[id] = { ...makeAnnotationToolDefaults(), ...toolDefaults(), - label: labels.activeLabel.value, + label: registry.activeLabel.value, ...tool, // updates label props if changed between sessions ...makePropsFromLabel(tool.label), @@ -152,7 +144,7 @@ export const useAnnotationTool = < }); // updates props controlled by labels - watch(labels.labels, () => { + watch(registry.allLabels, () => { toolIDs.value.forEach((id) => { const tool = toolByID.value[id]; const propsFromLabel = makePropsFromLabel(tool.label); @@ -182,7 +174,9 @@ export const useAnnotationTool = < return { tools: toolsSerialized, - labels: labels.labels.value, + labels: registry.serializeLabels( + toolsSerialized.flatMap((tool) => (tool.label ? [tool.label] : [])) + ), }; }; @@ -194,30 +188,24 @@ export const useAnnotationTool = < serialized: Maybe, dataIDMap: Record ) { - if (serialized?.labels) { - labels.clearDefaultLabels(); - } - const labelIDMap = Object.fromEntries( - Object.entries(serialized?.labels ?? {}).map(([id, label]) => { - const newID = labels.addLabel(label); // side effect in Array.map - return [id, newID]; - }) - ); + const resolveLabel = serialized?.labels + ? registry.adoptLabels(serialized.labels as Labels) + : () => ''; serialized?.tools - .map( - ({ imageID, label, ...rest }) => - ({ - ...rest, - imageID: dataIDMap[imageID], - label: (label && labelIDMap[label]) || '', - }) as ToolPatch - ) + .map(({ imageID, label, ...rest }) => { + const newImageID = dataIDMap[imageID]; + return { + ...rest, + imageID: newImageID, + label: resolveLabel(label, newImageID), + } as ToolPatch; + }) .forEach((tool) => addTool(tool)); } return { - ...labels, + ...registry, toolIDs, toolByID, tools, diff --git a/src/types/segmentation.ts b/src/types/segmentation.ts index 2cb0302f8..a3e17f51a 100644 --- a/src/types/segmentation.ts +++ b/src/types/segmentation.ts @@ -108,6 +108,9 @@ export function cssColorToRGBA(css: string): RGBAColor { return hexaToRGBA(expandShorthandHex(hex)); } +// Opaque colors keep the 6-digit form label colors are written in, so a color +// that round trips through a segment comes back byte-identical. export function rgbaToCssColor(rgba: RGBAColor) { - return rgbaToHexa(rgba); + const hexa = rgbaToHexa(rgba); + return rgba[3] === 255 ? hexa.slice(0, 7) : hexa; } From 8f6f34a15ac3783610744f03e0b7f67335c704d5 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 1 Sep 2026 00:51:51 -0400 Subject: [PATCH 006/221] feat(segmentation): active segment target and cross-image intent --- src/store/__tests__/segmentations.spec.ts | 227 +++++++++++++++++- src/store/segmentations.ts | 75 +++++- .../tools/__tests__/segmentRegistry.spec.ts | 27 +++ src/store/tools/segmentRegistry.ts | 25 +- 4 files changed, 344 insertions(+), 10 deletions(-) diff --git a/src/store/__tests__/segmentations.spec.ts b/src/store/__tests__/segmentations.spec.ts index d46f6acd5..e11d56c2d 100644 --- a/src/store/__tests__/segmentations.spec.ts +++ b/src/store/__tests__/segmentations.spec.ts @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; -import { CATEGORICAL_COLORS } from '@/src/config'; +import { CATEGORICAL_COLORS, DEFAULT_SEGMENT_MASKS } from '@/src/config'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import { useSegmentationStore } from '@/src/store/segmentations'; @@ -635,4 +635,229 @@ describe('segmentation store', () => { expect(store().artifactMeta[artifactId]?.parentImage).toBe('parent-img'); }); }); + + describe('active target and cross-image intent', () => { + /** Seats two images, each with an empty segmentation. */ + async function seatTwoImages() { + await seatImage('img-1'); + await seatImage('img-2', 'PET'); + return { + one: store().ensureSegmentationForImage('img-1').id, + two: store().ensureSegmentationForImage('img-2').id, + }; + } + + const segmentOf = (target: { segmentationId: string; segmentId: string }) => + store().getSegment(target.segmentationId, target.segmentId); + + it('sets the active target to the chosen segment', async () => { + const { one } = await seatTwoImages(); + const segment = store().createSegment(one, { name: 'Tumor' }); + + store().setActiveSegment(one, segment.id); + + expect(store().activeTarget).toEqual({ + segmentationId: one, + segmentId: segment.id, + }); + }); + + it('creates nothing on another image when the active segment is set', async () => { + const { one, two } = await seatTwoImages(); + const segment = store().createSegment(one, { name: 'Tumor' }); + + store().setActiveSegment(one, segment.id); + + expect(store().segmentations[two].order).toEqual([]); + expect(store().segmentations[two].segments).toEqual({}); + expect(store().segmentations[one].order).toEqual([segment.id]); + expect(Object.keys(store().artifactIndex)).toEqual([]); + }); + + it('creates no segmentation for an image that has none', async () => { + await seatImage('img-1'); + await seatImage('img-2', 'PET'); + const one = store().ensureSegmentationForImage('img-1').id; + const segment = store().createSegment(one, { name: 'Tumor' }); + + store().setActiveSegment(one, segment.id); + + expect(store().getSegmentationForImage('img-2')).toBeFalsy(); + expect(Object.keys(store().segmentations)).toEqual([one]); + }); + + it('resolves the image the active segment was set on to that segment', async () => { + const { one } = await seatTwoImages(); + const segment = store().createSegment(one, { name: 'Tumor' }); + store().setActiveSegment(one, segment.id); + + const target = store().resolveEditTarget('img-1'); + + expect(target).toEqual({ segmentationId: one, segmentId: segment.id }); + expect(store().segmentations[one].order).toEqual([segment.id]); + }); + + it('clones the active name and color into a fresh segment on another image', async () => { + const { one, two } = await seatTwoImages(); + const source = store().createSegment(one, { + name: 'Tumor', + color: [12, 34, 56, 255], + }); + store().setActiveSegment(one, source.id); + + const target = store().resolveEditTarget('img-2'); + + expect(target.segmentationId).toBe(two); + expect(target.segmentId).not.toBe(source.id); + const clone = segmentOf(target); + expect(clone.name).toBe('Tumor'); + expect([...clone.color]).toEqual([12, 34, 56, 255]); + expect(clone.visible).toBe(true); + expect(clone.locked).toBe(false); + expect(store().segmentations[two].order).toEqual([target.segmentId]); + expect(store().activeTarget).toEqual(target); + }); + + it('reuses the cloned target for the rest of the session', async () => { + const { one, two } = await seatTwoImages(); + const source = store().createSegment(one, { name: 'Tumor' }); + store().setActiveSegment(one, source.id); + + const first = store().resolveEditTarget('img-2'); + const back = store().resolveEditTarget('img-1'); + const second = store().resolveEditTarget('img-2'); + + expect(back).toEqual({ segmentationId: one, segmentId: source.id }); + expect(second).toEqual(first); + expect(store().segmentations[two].order).toEqual([first.segmentId]); + expect(store().segmentations[one].order).toEqual([source.id]); + }); + + it('keeps a clone independent of the segment it came from', async () => { + const { one } = await seatTwoImages(); + const source = store().createSegment(one, { + name: 'Tumor', + color: [12, 34, 56, 255], + }); + store().setActiveSegment(one, source.id); + const target = store().resolveEditTarget('img-2'); + + store().updateSegment(one, source.id, { + name: 'Lesion', + color: [9, 9, 9, 255], + }); + + expect(segmentOf(target).name).toBe('Tumor'); + expect([...segmentOf(target).color]).toEqual([12, 34, 56, 255]); + + store().updateSegment(target.segmentationId, target.segmentId, { + name: 'Metastasis', + }); + + expect(store().getSegment(one, source.id).name).toBe('Lesion'); + }); + + it('never merges with an existing segment of the same name', async () => { + const { one, two } = await seatTwoImages(); + const existing = store().createSegment(two, { name: 'Tumor' }); + const source = store().createSegment(one, { name: 'Tumor' }); + store().setActiveSegment(one, source.id); + + const target = store().resolveEditTarget('img-2'); + + expect(target.segmentId).not.toBe(existing.id); + expect(store().segmentations[two].order).toEqual([ + existing.id, + target.segmentId, + ]); + expect(store().getSegment(two, existing.id).name).toBe('Tumor'); + expect(segmentOf(target).name).toBe('Tumor'); + }); + + it('clones again when the recorded target has been deleted', async () => { + const { one, two } = await seatTwoImages(); + const source = store().createSegment(one, { name: 'Tumor' }); + store().setActiveSegment(one, source.id); + const first = store().resolveEditTarget('img-2'); + + store().deleteSegment(two, first.segmentId); + const second = store().resolveEditTarget('img-2'); + + expect(second.segmentationId).toBe(two); + expect(second.segmentId).not.toBe(first.segmentId); + expect(segmentOf(second).name).toBe('Tumor'); + expect(store().segmentations[two].order).toEqual([second.segmentId]); + }); + + it('starts a fresh intent on every setActiveSegment', async () => { + const { one, two } = await seatTwoImages(); + const first = store().createSegment(one, { name: 'Tumor' }); + const second = store().createSegment(one, { name: 'Node' }); + store().setActiveSegment(one, first.id); + const fromFirst = store().resolveEditTarget('img-2'); + + store().setActiveSegment(one, second.id); + const fromSecond = store().resolveEditTarget('img-2'); + + expect(fromSecond.segmentId).not.toBe(fromFirst.segmentId); + expect(segmentOf(fromSecond).name).toBe('Node'); + expect(segmentOf(fromFirst).name).toBe('Tumor'); + expect(store().segmentations[two].order).toEqual([ + fromFirst.segmentId, + fromSecond.segmentId, + ]); + }); + + it('seeds a segmentation and a default segment on the first edit of a session', async () => { + await seatImage('img-1'); + + const target = store().resolveEditTarget('img-1'); + + const segmentation = store().getSegmentationForImage('img-1'); + expect(segmentation!.id).toBe(target.segmentationId); + expect(segmentation!.order).toEqual([target.segmentId]); + const segment = segmentOf(target); + expect(segment.name).toBe(DEFAULT_SEGMENT_MASKS[0].name); + expect([...segment.color]).toEqual([...DEFAULT_SEGMENT_MASKS[0].color]); + expect(segment.visible).toBe(true); + expect(segment.locked).toBe(false); + expect(store().activeTarget).toEqual(target); + }); + + it('binds the seeded default segment to storage for its own image', async () => { + await seatImage('img-1'); + + const target = store().resolveEditTarget('img-1'); + const binding = store().ensureLabelmapBinding( + target.segmentationId, + target.segmentId + ); + + expect(store().artifactMeta[binding.artifactId].parentImage).toBe( + 'img-1' + ); + expect(binding.labelValue).toBeGreaterThan(0); + const resolved = store().resolveLabelmapBinding( + target.segmentationId, + target.segmentId + ); + expect(resolved!.labelmap).toBe( + store().artifactIndex[binding.artifactId] + ); + }); + + it('gives each image its own segment when no intent was ever set', async () => { + await seatImage('img-1'); + await seatImage('img-2', 'PET'); + + const first = store().resolveEditTarget('img-1'); + const second = store().resolveEditTarget('img-2'); + + expect(second.segmentationId).not.toBe(first.segmentationId); + expect(second.segmentId).not.toBe(first.segmentId); + expect(store().getSegmentationForImage('img-2')!.order).toEqual([ + second.segmentId, + ]); + }); + }); }); diff --git a/src/store/segmentations.ts b/src/store/segmentations.ts index 99e11507f..98dbee51c 100644 --- a/src/store/segmentations.ts +++ b/src/store/segmentations.ts @@ -1,8 +1,8 @@ import { defineStore } from 'pinia'; -import { computed, markRaw, reactive, toRaw, watch } from 'vue'; +import { computed, markRaw, reactive, shallowRef, toRaw, watch } from 'vue'; import type { RGBAColor } from '@kitware/vtk.js/types'; -import { CATEGORICAL_COLORS } from '@/src/config'; +import { CATEGORICAL_COLORS, DEFAULT_SEGMENT_MASKS } from '@/src/config'; import { onImageDeleted } from '@/src/composables/onImageDeleted'; import { useIdStore } from '@/src/store/id'; import { useImageCacheStore } from '@/src/store/image-cache'; @@ -12,8 +12,10 @@ import { makeDefaultSegmentGroupName, makeDefaultSegmentName, } from '@/src/store/segmentGroups'; -import type { ProcessingResultSource } from '@/src/types'; +import type { Maybe, ProcessingResultSource } from '@/src/types'; import type { + ActiveSegmentationTarget, + ActiveSegmentIntent, Extent3D, LabelmapBinding, LabelmapSegment, @@ -357,6 +359,69 @@ export const useSegmentationStore = defineStore('segmentation', () => { delete artifactOrderByParent[parentImageId]; } + // --- active target and cross-image intent --- // + + const activeTargetRef = shallowRef>(); + + // Session-only, never serialized: which segment the user means, and where + // that intent has already landed per image. + let intent: Maybe; + + const segmentAt = (target: ActiveSegmentationTarget) => + segmentations[target.segmentationId]?.segments[target.segmentId]; + + // A target whose segment is gone (deleted, or its catalog replaced) is none. + const activeTarget = computed(() => + activeTargetRef.value && segmentAt(activeTargetRef.value) + ? activeTargetRef.value + : undefined + ); + + function setActiveSegment(segmentationId: string, segmentId: string) { + const segment = getSegment(segmentationId, segmentId); + const target = { segmentationId, segmentId }; + intent = { + name: segment.name, + color: [...segment.color] as RGBAColor, + targetByImageId: { + [getSegmentation(segmentationId).parentImageId]: target, + }, + }; + activeTargetRef.value = target; + } + + function clearActiveSegment() { + intent = undefined; + activeTargetRef.value = undefined; + } + + /** + * The one entry point every edit path calls at operation time. Only this + * creates a segment; setting an active segment or viewing another image + * never does. Storage stays deferred to ensureLabelmapBinding. + */ + function resolveEditTarget(imageId: string) { + const recorded = intent?.targetByImageId[imageId]; + if (recorded && segmentAt(recorded)) { + activeTargetRef.value = recorded; + return recorded; + } + + // Identity is copied, never matched: a same-named segment is not the same + // segment. + const { name, color } = intent ?? DEFAULT_SEGMENT_MASKS[0]; + const segmentation = ensureSegmentationForImage(imageId); + const segment = createSegment(segmentation.id, { name, color }); + const target = { segmentationId: segmentation.id, segmentId: segment.id }; + intent = { + name, + color: [...color] as RGBAColor, + targetByImageId: { ...intent?.targetByImageId, [imageId]: target }, + }; + activeTargetRef.value = target; + return target; + } + // --- render sync --- // // The labelmap renderer colors by voxel value, so each artifact receives the @@ -410,6 +475,10 @@ export const useSegmentationStore = defineStore('segmentation', () => { artifactMeta, artifactOrderByParent, labelmapSegmentsByArtifact, + activeTarget, + setActiveSegment, + clearActiveSegment, + resolveEditTarget, getSegmentationForImage, ensureSegmentationForImage, getSegment, diff --git a/src/store/tools/__tests__/segmentRegistry.spec.ts b/src/store/tools/__tests__/segmentRegistry.spec.ts index e5ea0ff06..e23a3ff3d 100644 --- a/src/store/tools/__tests__/segmentRegistry.spec.ts +++ b/src/store/tools/__tests__/segmentRegistry.spec.ts @@ -175,6 +175,33 @@ describe('shared segment registry', () => { expect(registry.activeSegmentId.value).toBeFalsy(); }); + it('takes the active segment from the segmentation store', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + }); + const registry = createSharedSegmentRegistry(); + + segmentationStore().setActiveSegment(segmentation.id, segment.id); + + expect(registry.activeSegmentId.value).toBe(segment.id); + }); + + it('records the segment it activates on the segmentation store', () => { + const segmentation = seatAndView('img-1'); + const segment = segmentationStore().createSegment(segmentation.id, { + name: 'Tumor', + }); + const registry = createSharedSegmentRegistry(); + + registry.setActiveSegment(segment.id); + + expect(segmentationStore().activeTarget).toEqual({ + segmentationId: segmentation.id, + segmentId: segment.id, + }); + }); + it('scopes segments to the viewed image', () => { const first = seatAndView('img-1'); segmentationStore().createSegment(first.id, { name: 'Tumor' }); diff --git a/src/store/tools/segmentRegistry.ts b/src/store/tools/segmentRegistry.ts index 1c975b977..a767d9f97 100644 --- a/src/store/tools/segmentRegistry.ts +++ b/src/store/tools/segmentRegistry.ts @@ -80,9 +80,6 @@ export const createSharedSegmentRegistry = ( Record >; - // Temporary; C5 moves this onto the segmentation store's active target. - const activeLabel = ref(); - const segmentationFor = (imageId: Maybe) => imageId ? segmentationStore.getSegmentationForImage(imageId) : undefined; @@ -132,10 +129,24 @@ export const createSharedSegmentRegistry = ( ) ); + const activeSegmentId = computed( + () => segmentationStore.activeTarget?.segmentId + ); + const setActiveLabel = (id: string | undefined) => { - activeLabel.value = id; + const segmentation = id ? owningSegmentation(id) : undefined; + if (!id || !segmentation) { + segmentationStore.clearActiveSegment(); + return; + } + segmentationStore.setActiveSegment(segmentation.id, id); }; + const activeLabel = computed({ + get: () => activeSegmentId.value ?? undefined, + set: setActiveLabel, + }); + const setProps = (segmentId: string, props: ToolLabel) => { propsBySegment.value = { ...propsBySegment.value, @@ -182,12 +193,14 @@ export const createSharedSegmentRegistry = ( const segmentation = owningSegmentation(id); if (!segmentation) throw new Error('Label does not exist'); + // Read before deleting: the store drops the active target with the segment. + const wasActive = id === activeLabel.value; segmentationStore.deleteSegment(segmentation.id, id); propsBySegment.value = Object.fromEntries( Object.entries(propsBySegment.value).filter(([key]) => key !== id) ); - if (id === activeLabel.value) { + if (wasActive) { setActiveLabel(segments.value[0]?.id ?? ''); } }; @@ -277,7 +290,7 @@ export const createSharedSegmentRegistry = ( return { segments, - activeSegmentId: computed(() => activeLabel.value), + activeSegmentId, getSegment, setActiveSegment: (id: Maybe) => setActiveLabel(id ?? undefined), createSegment: (init?: { name?: string; color?: string }) => From 17e0880e65f77cf395b10b9baad6ba227972f4f6 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 1 Sep 2026 01:43:15 -0400 Subject: [PATCH 007/221] refactor(segmentation): retarget edit paths to stable segment identity --- src/components/ProcessWorkflow.vue | 6 +- src/components/SegmentGroupControls.vue | 26 ++- src/components/SegmentList.vue | 21 +- src/components/tools/paint/PaintWidget2D.vue | 6 +- src/components/tools/polygon/PolygonTool.vue | 86 ++----- .../polygon/__tests__/rasterizeTarget.spec.ts | 109 +++++++++ .../tools/polygon/rasterizeTarget.ts | 27 +++ src/processing/components/JobsModule.vue | 4 +- .../components/__tests__/JobsModule.spec.ts | 17 +- src/processing/composables/useInputStaging.ts | 4 +- .../engine/__tests__/mintLabelmap.spec.ts | 2 +- .../__tests__/sourceRefBindingContext.ts | 2 +- .../engine/__tests__/sourceRefs.spec.ts | 2 +- src/processing/engine/mintLabelmap.ts | 6 +- src/processing/engine/sourceRefs.ts | 4 +- .../__tests__/datasetRemoveCascade.spec.ts | 16 +- src/store/__tests__/fillHoles.spec.ts | 66 ++++-- src/store/__tests__/paintProcess.spec.ts | 169 ++++++++++---- src/store/segmentations.ts | 13 ++ src/store/tools/__tests__/paintTarget.spec.ts | 187 +++++++++++++++ src/store/tools/fillBetween.ts | 4 +- src/store/tools/fillHoles.ts | 21 +- src/store/tools/gaussianSmooth.ts | 7 +- src/store/tools/paint.ts | 213 ++++++------------ src/store/tools/paintProcess.ts | 95 ++++---- 25 files changed, 720 insertions(+), 393 deletions(-) create mode 100644 src/components/tools/polygon/__tests__/rasterizeTarget.spec.ts create mode 100644 src/components/tools/polygon/rasterizeTarget.ts create mode 100644 src/store/tools/__tests__/paintTarget.spec.ts diff --git a/src/components/ProcessWorkflow.vue b/src/components/ProcessWorkflow.vue index 743437caa..33baba6a5 100644 --- a/src/components/ProcessWorkflow.vue +++ b/src/components/ProcessWorkflow.vue @@ -58,7 +58,6 @@