From e41a6490f5c1fee4c34e4cced14280bab095cdde Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 14 Aug 2026 17:07:51 -0400 Subject: [PATCH 1/2] feat(processing): support multiple segmentation groups as job inputs Distinguish singular and plural labelmap inputs via the task spec's new multiple flag (sourced from the Slicer CLI XML multiple attribute): - A singular labelmap input binds the actively selected segmentation group, as before. - An input with multiple: true binds every segmentation group on the active dataset, serialized and staged one file per group; staged names stay unique when groups share a name. - Plurality is derived from the field that actually binds as labelmap after input-type resolution, so union-typed inputs cannot silently demote a plural input. - Zero segmentation groups fails closed for required inputs; the Jobs UI describes singular inputs as the active segment group and plural inputs as all groups on the dataset. Bump the backend contract to 0.2.0: multiple is an optional boolean on sourceRef parameters, documented in the generated schemas. Absent means singular, so existing backends are unaffected. --- backend-contract/generated/openapi.json | 6 +- .../generated/task-spec.schema.json | 4 + backend-contract/package.json | 2 +- .../processing/__tests__/task-spec.spec.ts | 11 + backend-contract/processing/openapi.ts | 2 +- backend-contract/processing/task-spec.ts | 6 + src/processing/components/JobsModule.vue | 82 ++++--- .../components/__tests__/JobsModule.spec.ts | 213 ++++++++++++++++++ .../components/widgets/FileWidget.vue | 11 +- .../widgets/__tests__/FileWidget.spec.ts | 74 ++++++ .../engine/__tests__/mintInput.spec.ts | 92 ++++---- .../engine/__tests__/mintLabelmap.spec.ts | 173 +++++++------- .../__tests__/sourceRefBindingContext.ts | 20 ++ .../engine/__tests__/sourceRefs.spec.ts | 127 +++++++++-- src/processing/engine/mintInput.ts | 23 +- src/processing/engine/mintLabelmap.ts | 47 ++-- src/processing/engine/sourceRefs.ts | 89 +++++--- 17 files changed, 732 insertions(+), 250 deletions(-) create mode 100644 src/processing/engine/__tests__/sourceRefBindingContext.ts diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index 3b922115c..7106ed57b 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -3,7 +3,7 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "VolView neutral backend contract", - "version": "0.1.0", + "version": "0.2.0", "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 2 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." }, "servers": [ @@ -715,6 +715,10 @@ "items": { "type": "string" } + }, + "multiple": { + "description": "When true, the parameter receives every segment group whose parent is the active dataset, serialized one file per group in store order. When absent or false, it receives only the actively selected group.", + "type": "boolean" } }, "required": [ diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index 2ea9d51ed..202ab41c0 100644 --- a/backend-contract/generated/task-spec.schema.json +++ b/backend-contract/generated/task-spec.schema.json @@ -308,6 +308,10 @@ "items": { "type": "string" } + }, + "multiple": { + "description": "When true, the parameter receives every segment group whose parent is the active dataset, serialized one file per group in store order. When absent or false, it receives only the actively selected group.", + "type": "boolean" } }, "required": [ diff --git a/backend-contract/package.json b/backend-contract/package.json index 0b8f5e62c..1afe62c13 100644 --- a/backend-contract/package.json +++ b/backend-contract/package.json @@ -1,5 +1,5 @@ { "name": "@volview/backend-contract", - "version": "0.1.0", + "version": "0.2.0", "private": true } diff --git a/backend-contract/processing/__tests__/task-spec.spec.ts b/backend-contract/processing/__tests__/task-spec.spec.ts index a53804f5f..96f78cd6f 100644 --- a/backend-contract/processing/__tests__/task-spec.spec.ts +++ b/backend-contract/processing/__tests__/task-spec.spec.ts @@ -79,6 +79,17 @@ describe('task-spec field kinds', () => { ); }); + it('preserves sourceRef multiplicity when a backend declares it', () => { + expect( + taskParameterSchema.parse({ + kind: 'sourceRef', + id: 'segmentations', + accepts: ['labelmap'], + multiple: true, + }) + ).toMatchObject({ multiple: true }); + }); + it('carries numeric constraints + default on an int param', () => { expect(paramById('synthetic-all-kinds', 'radius')).toMatchObject({ kind: 'int', diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts index 413ef453a..8a62458d6 100644 --- a/backend-contract/processing/openapi.ts +++ b/backend-contract/processing/openapi.ts @@ -474,7 +474,7 @@ export const buildOpenApiDocument = (): Record => ({ // VERSION / specVersion) below. It is deliberately literal, not derived from // the shape-version constants — the artifact and the shapes version on // separate clocks. - version: '0.1.0', + version: '0.2.0', description: 'DRAFT 0.x — shapes may change until a second backend passes the ' + 'conformance kit (the pinned 1.0 criterion). ' + diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts index fa6d986ad..248595639 100644 --- a/backend-contract/processing/task-spec.ts +++ b/backend-contract/processing/task-spec.ts @@ -132,6 +132,12 @@ const sourceRefParam = z.object({ kind: z.literal('sourceRef'), ...paramCommon, accepts: z.array(typeTagSchema).min(1), + multiple: z + .boolean() + .optional() + .describe( + 'When true, the parameter receives every segment group whose parent is the active dataset, serialized one file per group in store order. When absent or false, it receives only the actively selected group.' + ), }); const boundsParam = z.object({ diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index fd85cc802..c7bd60ded 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -586,6 +586,34 @@ function refreshValidation( // // The literal 'seg.nrrd' name is required for segment names and colors to be // embedded in the serialized output. +async function stageSegmentGroupInput( + p: ProcessingProvider, + segmentGroupId: string, + // Group names are not unique, so a plural parameter numbers its files to keep + // them distinguishable in the job folder. + ordinal?: number +): Promise { + const metadata = segmentGroupStore.metadataByID[segmentGroupId]; + const labelmap = segmentGroupStore.dataIndex[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 suffix = ordinal === undefined ? '' : `-${ordinal}`; + return p.stageInput({ + file: new Blob([serialized]), + descriptor: { + type: TYPE_TAG_LABELMAP, + name: `${metadata.name}${suffix}.seg.nrrd`, + referenceImage: { + ...referenceImage, + type: 'image', + }, + }, + }); +} + async function stageLabelmapInputs( p: ProcessingProvider, model: TaskFormModel, @@ -595,32 +623,18 @@ async function stageLabelmapInputs( if (targets.length === 0) return {}; const staged = await Promise.all( - targets.map(async ([parameterId, segmentGroupId]) => { - const metadata = segmentGroupStore.metadataByID[segmentGroupId]; - const labelmap = segmentGroupStore.dataIndex[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 name = `${metadata.name}.seg.nrrd`; - const uris = await p.stageInput({ - file: new Blob([serialized]), - descriptor: { - type: TYPE_TAG_LABELMAP, - name, - referenceImage: { - ...referenceImage, - type: 'image', - }, - }, - }); + targets.map(async ([parameterId, segmentGroupIds]) => { + const uris = ( + await Promise.all( + segmentGroupIds.map((groupId, index) => + stageSegmentGroupInput( + p, + groupId, + segmentGroupIds.length > 1 ? index + 1 : undefined + ) + ) + ) + ).flat(); return [parameterId, mintLabelmapValue(uris)] as const; }) ); @@ -688,10 +702,8 @@ async function stageAnnotationInputs( }; } -// Binding can fall back to the current image's sole group, so reading -// `paintStore.activeSegmentGroupID` directly would mislabel the job. type SourceRefContext = { - labelmapGroups: Record; + labelmapGroups: Record; types: Record; imageName: string | undefined; annotationCount: number; @@ -713,8 +725,12 @@ function boundLabelmapName( refs: SourceRefContext, parameterId: string ): string | undefined { - const groupId = refs.labelmapGroups[parameterId]; - return groupId ? segmentGroupStore.metadataByID[groupId]?.name : undefined; + const groupIds = refs.labelmapGroups[parameterId] ?? []; + const names = groupIds.map( + (groupId) => + segmentGroupStore.metadataByID[groupId]?.name ?? 'unnamed segment group' + ); + return names.length > 0 ? names.join(', ') : undefined; } // The bound value is a whole set of tools rather than one named resource, so @@ -755,7 +771,9 @@ function formatProcessingValue( ): string { if (field.kind === 'sourceRef') { if (refs.types[field.id] === TYPE_TAG_LABELMAP) { - return boundLabelmapName(refs, field.id) ?? 'bound segment group'; + // A bound param always names its groups, so the fallback is the optional + // param that bound nothing. + return boundLabelmapName(refs, field.id) ?? 'not provided'; } if (refs.types[field.id] === TYPE_TAG_ANNOTATIONS) { return boundAnnotationsName(refs); diff --git a/src/processing/components/__tests__/JobsModule.spec.ts b/src/processing/components/__tests__/JobsModule.spec.ts index c97c97c75..3ca96eacc 100644 --- a/src/processing/components/__tests__/JobsModule.spec.ts +++ b/src/processing/components/__tests__/JobsModule.spec.ts @@ -21,12 +21,25 @@ vi.mock('@/src/processing/engine/transport', () => ({ createEngineTransport: (config: { id: string }) => registry.get(config.id), })); +// `writeSegmentation` spawns a real Worker; keep the IO module out of the test. +const ioMocks = vi.hoisted(() => ({ + readImage: vi.fn(), + writeSegmentation: vi.fn(async () => new Uint8Array([1, 2, 3])), +})); +vi.mock('@/src/io/readWriteImage', () => ({ + readImage: ioMocks.readImage, + writeSegmentation: ioMocks.writeSegmentation, +})); + import JobsModule from '@/src/processing/components/JobsModule.vue'; import TaskPicker from '@/src/processing/components/TaskPicker.vue'; import TaskForm from '@/src/processing/components/TaskForm.vue'; 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 { useMessageStore } from '@/src/store/messages'; import { useViewStore } from '@/src/store/views'; const cfg = (id: string): ProcessingProviderConfig => ({ @@ -407,3 +420,203 @@ describe('JobsModule — race-free provider/task selection', () => { }); }); }); + +describe('JobsModule — segment group staging', () => { + let pinia: ReturnType; + + beforeEach(() => { + registry.clear(); + ioMocks.writeSegmentation.mockClear(); + pinia = createPinia().use(CorePiniaProviderPlugin()); + createApp({}).use(pinia); + setActivePinia(pinia); + }); + + const slotStub = { template: '
' }; + + const mount = () => + shallowMount(JobsModule, { + global: { + plugins: [pinia], + stubs: { + 'v-select': true, + 'v-expansion-panels': slotStub, + 'v-expansion-panel': slotStub, + 'v-expansion-panel-title': slotStub, + 'v-expansion-panel-text': slotStub, + }, + }, + }); + + const labelmapSpec = (multiple: boolean): TaskSpecEnvelope => ({ + specVersion: 1, + id: 'seg', + title: 'Segment', + parameters: [ + { + kind: 'sourceRef', + id: 'inputVolume', + accepts: ['image'], + required: true, + }, + { + kind: 'sourceRef', + id: 'inputSeg', + accepts: ['labelmap'], + required: true, + ...(multiple ? { multiple: true } : {}), + }, + ], + outputs: [], + }); + + const seedActiveImage = () => { + useDatasetStore().addDataSources([ + { + dataID: 'image-1', + dataSource: { + type: 'uri', + uri: 'girder://file/image-1', + name: 'image.nrrd', + }, + }, + ]); + useViewStore().setDataForAllViews('image-1'); + }; + + // Painted groups, in the order the store hands them back. + const seedGroups = (names: [string, string][]) => { + const store = useSegmentGroupStore(); + names.forEach(([id, name]) => { + store.dataIndex[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); + }); + }; + + const stagingProvider = (spec: TaskSpecEnvelope): FakeProvider => { + const p = makeProvider('P'); + p.listTasks = vi.fn().mockResolvedValue([{ id: 'seg', title: 'Segment' }]); + p.getTaskSpec = vi.fn().mockResolvedValue(spec); + p.stageInput = vi.fn(async (request) => [ + `girder://staged/${request.descriptor.name}`, + ]); + return p; + }; + + const submit = async (spec: TaskSpecEnvelope) => { + const p = stagingProvider(spec); + const store = useProcessingJobsStore(); + registerFake(store, p); + + const wrapper = mount(); + await flushPromises(); + + const form = wrapper.findComponent(TaskForm); + expect(form.props('issues')).toEqual([]); + + const submitSpy = vi.spyOn(store, 'submitJob').mockResolvedValue('job-1'); + form.vm.$emit('submit', form.props('values')); + await flushPromises(); + return { provider: p, submitSpy }; + }; + + it('stages every group of a multiple param in store order', async () => { + seedActiveImage(); + seedGroups([ + ['group-1', 'Tumor'], + ['group-2', 'Liver'], + ]); + + const { provider, submitSpy } = await submit(labelmapSpec(true)); + + expect(provider.stageInput).toHaveBeenCalledTimes(2); + expect(submitSpy).toHaveBeenCalledTimes(1); + expect(submitSpy.mock.calls[0][2].inputSeg).toEqual({ + type: 'labelmap', + uris: [ + 'girder://staged/Tumor-1.seg.nrrd', + 'girder://staged/Liver-2.seg.nrrd', + ], + }); + }); + + it('keeps staged file names unique for identically named groups', async () => { + seedActiveImage(); + seedGroups([ + ['group-1', 'Tumor'], + ['group-2', 'Tumor'], + ]); + + const { provider, submitSpy } = await submit(labelmapSpec(true)); + + const names = provider.stageInput.mock.calls.map( + (call) => call[0].descriptor.name + ); + expect(new Set(names).size).toBe(2); + expect(names).toEqual(['Tumor-1.seg.nrrd', 'Tumor-2.seg.nrrd']); + expect(submitSpy.mock.calls[0][2].inputSeg).toEqual({ + type: 'labelmap', + uris: [ + 'girder://staged/Tumor-1.seg.nrrd', + 'girder://staged/Tumor-2.seg.nrrd', + ], + }); + }); + + it('stages only the active group of a singular param', async () => { + seedActiveImage(); + seedGroups([ + ['group-1', 'Tumor'], + ['group-2', 'Liver'], + ]); + usePaintToolStore().setActiveSegmentGroup('group-2'); + + const { provider, submitSpy } = await submit(labelmapSpec(false)); + + expect(provider.stageInput).toHaveBeenCalledTimes(1); + expect(submitSpy.mock.calls[0][2].inputSeg).toEqual({ + type: 'labelmap', + uris: ['girder://staged/Liver.seg.nrrd'], + }); + }); + + it('reports a staging failure and submits nothing', async () => { + seedActiveImage(); + seedGroups([ + ['group-1', 'Tumor'], + ['group-2', 'Liver'], + ]); + + const p = stagingProvider(labelmapSpec(true)); + p.stageInput = vi.fn(async (request) => { + if (request.descriptor.name === 'Liver-2.seg.nrrd') + throw new Error('upload rejected'); + return ['girder://staged/Tumor-1.seg.nrrd']; + }); + const store = useProcessingJobsStore(); + registerFake(store, p); + + const wrapper = mount(); + await flushPromises(); + const form = wrapper.findComponent(TaskForm); + const submitSpy = vi.spyOn(store, 'submitJob'); + form.vm.$emit('submit', form.props('values')); + await flushPromises(); + + expect(submitSpy).not.toHaveBeenCalled(); + expect(useMessageStore().messages).toEqual([ + expect.objectContaining({ + title: 'Failed to stage segment group input', + }), + ]); + // The form is usable again rather than stuck mid-submission. + expect(form.props('submitting')).toBe(false); + }); +}); diff --git a/src/processing/components/widgets/FileWidget.vue b/src/processing/components/widgets/FileWidget.vue index 64914c7fd..0794ba810 100644 --- a/src/processing/components/widgets/FileWidget.vue +++ b/src/processing/components/widgets/FileWidget.vue @@ -58,6 +58,10 @@ const IMAGE_KIND = { noun: 'image' as SourceRefNoun, }; +const multiple = computed( + () => props.param.kind === 'sourceRef' && props.param.multiple === true +); + // Before the binder has run, a param accepting exactly one type already names // its kind. const kind = computed(() => { @@ -66,7 +70,10 @@ const kind = computed(() => { ? props.param.accepts[0] : undefined; const type = props.boundType ?? declared; - return (type ? KINDS[type] : undefined) ?? IMAGE_KIND; + const resolved = (type ? KINDS[type] : undefined) ?? IMAGE_KIND; + return type === TYPE_TAG_LABELMAP && multiple.value + ? { ...resolved, caption: 'Segment groups on active dataset' } + : resolved; }); const OPTIONAL_UNBOUND_STATES = new Set([ @@ -88,7 +95,7 @@ const optionalUnbound = computed( const bindingMessage = computed(() => props.binding && !optionalUnbound.value - ? bindingStateMessage(props.binding, kind.value.noun) + ? bindingStateMessage(props.binding, kind.value.noun, multiple.value) : undefined ); diff --git a/src/processing/components/widgets/__tests__/FileWidget.spec.ts b/src/processing/components/widgets/__tests__/FileWidget.spec.ts index 42904da7f..9854d5df0 100644 --- a/src/processing/components/widgets/__tests__/FileWidget.spec.ts +++ b/src/processing/components/widgets/__tests__/FileWidget.spec.ts @@ -71,3 +71,77 @@ describe('FileWidget optional source refs', () => { expect(wrapper.get('.input-value').text()).toMatch(/place a ruler/i); }); }); + +const labelmapParam = ( + overrides: Partial> = {} +): VolViewTaskParameter => ({ + kind: 'sourceRef', + id: 'inputSeg', + accepts: ['labelmap'], + required: true, + ...overrides, +}); + +const mountLabelmap = ( + param: VolViewTaskParameter, + props: Record = {} +) => + shallowMount(FileWidget, { + props: { param, modelValue: null, ...props }, + global, + }); + +describe('FileWidget plural segment groups', () => { + it('names the whole group set for a multiple param', () => { + const wrapper = mountLabelmap(labelmapParam({ multiple: true })); + + expect(wrapper.get('.key-text').text()).toBe( + 'Segment groups on active dataset' + ); + }); + + it('names only the active group for a singular param', () => { + const wrapper = mountLabelmap(labelmapParam()); + + expect(wrapper.get('.key-text').text()).toBe('Active segment group'); + }); + + it('keeps the plural caption once the binder resolves a union param', () => { + const wrapper = mountLabelmap( + labelmapParam({ accepts: ['image', 'labelmap'], multiple: true }), + { boundType: 'labelmap' } + ); + + expect(wrapper.get('.key-text').text()).toBe( + 'Segment groups on active dataset' + ); + }); + + it('leaves a multiple image param on the dataset caption', () => { + const wrapper = mountLabelmap( + labelmapParam({ accepts: ['image'], multiple: true }) + ); + + expect(wrapper.get('.key-text').text()).toBe('Active dataset'); + }); + + it('drops the select remedy from the unbound message', () => { + const wrapper = mountLabelmap(labelmapParam({ multiple: true }), { + binding: 'no-segment-group', + }); + + expect(wrapper.get('.input-value').text()).toBe( + 'Paint a segment group on the active dataset first.' + ); + }); + + it('offers the select remedy for a singular param', () => { + const wrapper = mountLabelmap(labelmapParam(), { + binding: 'no-segment-group', + }); + + expect(wrapper.get('.input-value').text()).toBe( + 'Paint or select a segment group first.' + ); + }); +}); diff --git a/src/processing/engine/__tests__/mintInput.spec.ts b/src/processing/engine/__tests__/mintInput.spec.ts index ad2d70829..aec289440 100644 --- a/src/processing/engine/__tests__/mintInput.spec.ts +++ b/src/processing/engine/__tests__/mintInput.spec.ts @@ -6,13 +6,14 @@ import { collectProvenanceUris, deriveFormat, mintInputValue, - bindImageInputs, imageInputFields, } from '../mintInput'; +import { bindSourceRefs, type SourceRefBindingContext } from '../sourceRefs'; import { buildTaskFormModel, type TaskFormModel } from '../formModel'; import { parseTaskSpecEnvelope } from '../taskSpec'; import { loadFixture } from '@/backend-contract/processing/__tests__/loadFixtures'; import type { FormField } from '../formModel'; +import { createSourceRefBindingContext } from './sourceRefBindingContext'; const uriSource = (uri: string): DataSource => ({ type: 'uri', @@ -90,6 +91,14 @@ const imageParamModel = ( hidden: [], }); +const context = ( + activeDataSource: DataSource | undefined +): SourceRefBindingContext => + createSourceRefBindingContext({ + activeDataSource, + getDataSource: () => undefined, + }); + describe('mintInputValue matches the input-value golden fixtures', () => { it('mints a dicom-series image from a remote DICOM collection', () => { const fixture = loadFixture('wire/input-value.dicom-series.json'); @@ -157,15 +166,15 @@ describe('mintInputValue fails closed for no-provenance volumes', () => { }); }); -describe('bindImageInputs auto-binds the active dataset', () => { +describe('image binding auto-binds the active dataset', () => { it('binds the sole image param to the active volume', () => { - const result = bindImageInputs( + const bindings = bindSourceRefs( imageParamModel(), - remoteFile('/api/x/scan.nrrd', 'scan.nrrd') + context(remoteFile('/api/x/scan.nrrd', 'scan.nrrd')) ); - expect(result.states.inputVolume).toBe('bound'); - expect(result.issues).toHaveLength(0); - expect(result.values.inputVolume).toEqual({ + expect(bindings.states.inputVolume).toBe('bound'); + expect(bindings.issues).toHaveLength(0); + expect(bindings.image.values.inputVolume).toEqual({ type: 'image', format: 'nrrd', uris: ['/api/x/scan.nrrd'], @@ -173,22 +182,25 @@ describe('bindImageInputs auto-binds the active dataset', () => { }); it('fails closed (no-provenance) + refuses submit for a local-drop volume', () => { - const result = bindImageInputs(imageParamModel(), localFile('local.nrrd')); - expect(result.states.inputVolume).toBe('no-provenance'); - expect(result.values.inputVolume).toBeNull(); - expect(result.issues).toHaveLength(1); - expect(result.issues[0].parameter).toBe('inputVolume'); - expect(result.issues[0].message).toMatch(/not loaded from the server/i); + const bindings = bindSourceRefs( + imageParamModel(), + context(localFile('local.nrrd')) + ); + expect(bindings.states.inputVolume).toBe('no-provenance'); + expect(bindings.image.values.inputVolume).toBeNull(); + expect(bindings.issues).toHaveLength(1); + expect(bindings.issues[0].parameter).toBe('inputVolume'); + expect(bindings.issues[0].message).toMatch(/not loaded from the server/i); }); it('fails closed (no-provenance) for a mixed remote/local collection', () => { - const result = bindImageInputs( + const bindings = bindSourceRefs( imageParamModel(), - mixedProvenanceVolume(['a/1.dcm', 'a/2.dcm']) + context(mixedProvenanceVolume(['a/1.dcm', 'a/2.dcm'])) ); - expect(result.states.inputVolume).toBe('no-provenance'); - expect(result.values.inputVolume).toBeNull(); - expect(result.issues).toHaveLength(1); + expect(bindings.states.inputVolume).toBe('no-provenance'); + expect(bindings.image.values.inputVolume).toBeNull(); + expect(bindings.issues).toHaveLength(1); }); it('fails closed (ambiguous) when more than one image param is present', () => { @@ -201,14 +213,14 @@ describe('bindImageInputs auto-binds the active dataset', () => { ], hidden: [], }; - const result = bindImageInputs( + const bindings = bindSourceRefs( model, - remoteFile('/api/x/scan.nrrd', 'scan.nrrd') + context(remoteFile('/api/x/scan.nrrd', 'scan.nrrd')) ); - expect(result.states.ct).toBe('ambiguous'); - expect(result.states.pet).toBe('ambiguous'); - expect(result.values).toEqual({ ct: null, pet: null }); - expect(result.issues).toHaveLength(1); + expect(bindings.states.ct).toBe('ambiguous'); + expect(bindings.states.pet).toBe('ambiguous'); + expect(bindings.image.values).toEqual({ ct: null, pet: null }); + expect(bindings.issues).toHaveLength(1); }); it('is a no-op when the task has no image input', () => { @@ -218,9 +230,11 @@ describe('bindImageInputs auto-binds the active dataset', () => { fields: [{ kind: 'int', id: 'radius', default: 1 }], hidden: [], }; - expect( - bindImageInputs(model, remoteFile('/api/x/scan.nrrd', 'scan.nrrd')) - ).toEqual({ + const bindings = bindSourceRefs( + model, + context(remoteFile('/api/x/scan.nrrd', 'scan.nrrd')) + ); + expect(bindings.image).toEqual({ values: {}, states: {}, issues: [], @@ -228,23 +242,23 @@ describe('bindImageInputs auto-binds the active dataset', () => { }); it('refuses submit for a required image input with no active dataset', () => { - const result = bindImageInputs(imageParamModel(), undefined); - expect(result.states.inputVolume).toBe('unbound'); - expect(result.issues).toHaveLength(1); - expect(result.issues[0].message).toMatch(/required/i); + const bindings = bindSourceRefs(imageParamModel(), context(undefined)); + expect(bindings.states.inputVolume).toBe('unbound'); + expect(bindings.issues).toHaveLength(1); + expect(bindings.issues[0].message).toMatch(/required/i); }); it('does not block an OPTIONAL image input with no active dataset', () => { - const result = bindImageInputs( + const bindings = bindSourceRefs( imageParamModel({ required: false }), - undefined + context(undefined) ); - expect(result.states.inputVolume).toBe('unbound'); - expect(result.issues).toHaveLength(0); + expect(bindings.states.inputVolume).toBe('unbound'); + expect(bindings.issues).toHaveLength(0); }); }); -describe('bindImageInputs over a real task-spec fixture', () => { +describe('image binding over a real task-spec fixture', () => { it('finds and binds the image inputVolume from provenance', () => { const model = buildTaskFormModel( parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-all-kinds.json')) @@ -254,9 +268,9 @@ describe('bindImageInputs over a real task-spec fixture', () => { const uris = ( loadFixture('wire/input-value.dicom-series.json') as { uris: string[] } ).uris; - const result = bindImageInputs(model, dicomVolume(uris)); - expect(result.states.inputVolume).toBe('bound'); - expect(result.values.inputVolume).toEqual({ + const bindings = bindSourceRefs(model, context(dicomVolume(uris))); + expect(bindings.states.inputVolume).toBe('bound'); + expect(bindings.image.values.inputVolume).toEqual({ type: 'image', format: 'dicom-series', uris, diff --git a/src/processing/engine/__tests__/mintLabelmap.spec.ts b/src/processing/engine/__tests__/mintLabelmap.spec.ts index 1066395e5..80c35e153 100644 --- a/src/processing/engine/__tests__/mintLabelmap.spec.ts +++ b/src/processing/engine/__tests__/mintLabelmap.spec.ts @@ -3,14 +3,14 @@ import { describe, it, expect } from 'vitest'; import type { DataSource } from '@/src/io/import/dataSource'; import { labelmapInputFields, - resolveLabelmapGroup, - bindLabelmapInputs, + resolveLabelmapGroups, mintLabelmapValue, mintLabelmapReferenceImage, type SegmentGroupView, } from '../mintLabelmap'; -import { bindImageInputs } from '../mintInput'; +import { bindSourceRefs, type SourceRefBindingContext } from '../sourceRefs'; import type { TaskFormModel, FormField } from '../formModel'; +import { createSourceRefBindingContext } from './sourceRefBindingContext'; const labelmapModel = ( overrides: Partial> = {} @@ -57,6 +57,17 @@ const remoteFile = (uri: string): DataSource => ({ name: 'scan.nrrd', }); +const context = ( + overrides: Partial = {} +): SourceRefBindingContext => + createSourceRefBindingContext({ + activeDataSource: remoteFile('/api/x/scan.nrrd'), + backgroundImageId: 'bg', + segmentGroups: viewOf({}), + getDataSource: () => remoteFile('/api/x/scan.nrrd'), + ...overrides, + }); + describe('labelmapInputFields', () => { it('selects sourceRef params that accept a labelmap', () => { const model: TaskFormModel = { @@ -73,78 +84,71 @@ describe('labelmapInputFields', () => { }); }); -describe('resolveLabelmapGroup — fallback chain', () => { - it('branch 1: the paint-active group (guard passes)', () => { +describe('resolveLabelmapGroups', () => { + it('returns the sole base image group for a singular parameter', () => { const view = viewOf({ g1: 'bg' }); - expect(resolveLabelmapGroup('bg', 'g1', view)).toEqual({ + expect(resolveLabelmapGroups('bg', null, false, view)).toEqual({ kind: 'resolved', - groupId: 'g1', + groupIds: ['g1'], }); }); - it('branch 1 wins over multiple groups: paint-active disambiguates', () => { + it('returns the selected group for a singular parameter', () => { const view = viewOf({ g1: 'bg', g2: 'bg' }); - expect(resolveLabelmapGroup('bg', 'g2', view)).toEqual({ + expect(resolveLabelmapGroups('bg', 'g2', false, view)).toEqual({ kind: 'resolved', - groupId: 'g2', + groupIds: ['g2'], }); }); - it("branch 2: the background's ONLY segment group when none is paint-active", () => { - const view = viewOf({ g1: 'bg' }); - expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + it('returns every base image group for a multiple parameter', () => { + const view = viewOf({ g1: 'bg', g2: 'bg' }); + expect(resolveLabelmapGroups('bg', 'g2', true, view)).toEqual({ kind: 'resolved', - groupId: 'g1', + groupIds: ['g1', 'g2'], }); }); - it('branch 3: fail closed when the background has no segment group', () => { - const view = viewOf({ gOther: 'other' }); - expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + it('fails closed for a singular parameter with ambiguous unselected groups', () => { + const view = viewOf({ g1: 'bg', g2: 'bg' }); + expect(resolveLabelmapGroups('bg', null, false, view)).toEqual({ kind: 'unresolved', }); }); - it('branch 3: multiple groups, none paint-active → fail closed (no v1 picker)', () => { - const view = viewOf({ g1: 'bg', g2: 'bg' }); - expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + it('fails closed when the background has no segment group', () => { + const view = viewOf({ gOther: 'other' }); + expect(resolveLabelmapGroups('bg', null, true, view)).toEqual({ kind: 'unresolved', }); }); it('fails closed when there is no bound background', () => { const view = viewOf({ g1: 'bg' }); - expect(resolveLabelmapGroup(undefined, 'g1', view)).toEqual({ + expect(resolveLabelmapGroups(undefined, 'g1', true, view)).toEqual({ kind: 'unresolved', }); }); }); -describe('resolveLabelmapGroup — parentImage guard', () => { - it('rejects a paint-active group whose parentImage is not the background', () => { +describe('resolveLabelmapGroups — parentImage guard', () => { + it('does not include groups belonging to another image', () => { const view = viewOf({ g1: 'other', g2: 'bg' }); - expect(resolveLabelmapGroup('bg', 'g1', view)).toEqual({ + expect(resolveLabelmapGroups('bg', null, true, view)).toEqual({ kind: 'resolved', - groupId: 'g2', + groupIds: ['g2'], }); }); - it('fails closed when the only paint-active group belongs to another image', () => { + it('fails closed when the only group belongs to another image', () => { const view = viewOf({ g1: 'other' }); - expect(resolveLabelmapGroup('bg', 'g1', view)).toEqual({ - kind: 'unresolved', - }); - }); - - it('branch 2 never crosses images: an only-group on another image is not used', () => { - const view = viewOf({ g1: 'other' }); - expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + expect(resolveLabelmapGroups('bg', null, true, view)).toEqual({ kind: 'unresolved', }); }); }); -describe('bindLabelmapInputs', () => { +describe('labelmap binding through bindSourceRefs', () => { it('is a no-op when the task has no labelmap input', () => { const model: TaskFormModel = { id: 'task', @@ -152,48 +156,50 @@ describe('bindLabelmapInputs', () => { fields: [{ kind: 'int', id: 'radius', default: 1 }], hidden: [], }; - expect(bindLabelmapInputs(model, 'bg', 'g1', viewOf({ g1: 'bg' }))).toEqual( - { - groups: {}, - states: {}, - issues: [], - } + const bindings = bindSourceRefs( + model, + context({ segmentGroups: viewOf({ g1: 'bg' }) }) ); + expect(bindings.labelmap).toEqual({ + groups: {}, + states: {}, + issues: [], + }); }); - it('binds the sole labelmap param to the resolved group', () => { - const result = bindLabelmapInputs( - labelmapModel(), - 'bg', - 'g1', - viewOf({ g1: 'bg' }) + it('fails closed (no-segment-group) + refuses submit when unresolved', () => { + const bindings = bindSourceRefs(labelmapModel(), context()); + expect(bindings.states.inputSeg).toBe('no-segment-group'); + expect(bindings.labelmap.groups).toEqual({}); + expect(bindings.issues).toHaveLength(1); + expect(bindings.issues[0].parameter).toBe('inputSeg'); + expect(bindings.issues[0].message).toMatch( + /paint or select a segment group/i ); - expect(result.states.inputSeg).toBe('bound'); - expect(result.groups.inputSeg).toBe('g1'); - expect(result.issues).toHaveLength(0); }); - it('fails closed (no-segment-group) + refuses submit when unresolved', () => { - const result = bindLabelmapInputs(labelmapModel(), 'bg', null, viewOf({})); - expect(result.states.inputSeg).toBe('no-segment-group'); - expect(result.groups).toEqual({}); - expect(result.issues).toHaveLength(1); - expect(result.issues[0].parameter).toBe('inputSeg'); - expect(result.issues[0].message).toMatch( - /paint or select a segment group/i + it('fails closed for a REQUIRED multiple labelmap input with no group', () => { + const bindings = bindSourceRefs( + labelmapModel({ multiple: true }), + context() + ); + expect(bindings.states.inputSeg).toBe('no-segment-group'); + expect(bindings.labelmap.groups).toEqual({}); + expect(bindings.issues).toHaveLength(1); + // Selecting a group is no remedy for a param that takes all of them. + expect(bindings.issues[0].message).toBe( + 'Paint a segment group on the active dataset first.' ); }); it('does not block an OPTIONAL labelmap input with no segment group', () => { - const result = bindLabelmapInputs( + const bindings = bindSourceRefs( labelmapModel({ required: false }), - 'bg', - null, - viewOf({}) + context() ); - expect(result.states.inputSeg).toBe('no-segment-group'); - expect(result.groups).toEqual({}); - expect(result.issues).toHaveLength(0); + expect(bindings.states.inputSeg).toBe('no-segment-group'); + expect(bindings.labelmap.groups).toEqual({}); + expect(bindings.issues).toHaveLength(0); }); it('fails closed (ambiguous) when more than one labelmap param is present', () => { @@ -216,11 +222,17 @@ describe('bindLabelmapInputs', () => { ], hidden: [], }; - const result = bindLabelmapInputs(model, 'bg', 'g1', viewOf({ g1: 'bg' })); - expect(result.states.segA).toBe('ambiguous'); - expect(result.states.segB).toBe('ambiguous'); - expect(result.groups).toEqual({}); - expect(result.issues).toHaveLength(1); + const bindings = bindSourceRefs( + model, + context({ + activeSegmentGroupId: 'g1', + segmentGroups: viewOf({ g1: 'bg' }), + }) + ); + expect(bindings.states.segA).toBe('ambiguous'); + expect(bindings.states.segB).toBe('ambiguous'); + expect(bindings.labelmap.groups).toEqual({}); + expect(bindings.issues).toHaveLength(1); }); }); @@ -236,21 +248,18 @@ describe('no-provenance background blocks the labelmap flow for free', () => { hidden: [], }; - const image = bindImageInputs(model, localFile('local.nrrd')); - const labelmap = bindLabelmapInputs( + const bindings = bindSourceRefs( model, - 'bg', - 'seg', - viewOf({ seg: 'bg' }) + context({ + activeDataSource: localFile('local.nrrd'), + segmentGroups: viewOf({ seg: 'bg' }), + }) ); - expect(labelmap.states.seg).toBe('bound'); - expect(labelmap.issues).toHaveLength(0); - - const combined = [...image.issues, ...labelmap.issues]; - expect(combined).toHaveLength(1); - expect(combined[0].parameter).toBe('bg'); - expect(combined[0].message).toMatch(/not loaded from the server/i); + expect(bindings.states.seg).toBe('bound'); + expect(bindings.issues).toHaveLength(1); + expect(bindings.issues[0].parameter).toBe('bg'); + expect(bindings.issues[0].message).toMatch(/not loaded from the server/i); }); }); diff --git a/src/processing/engine/__tests__/sourceRefBindingContext.ts b/src/processing/engine/__tests__/sourceRefBindingContext.ts new file mode 100644 index 000000000..70b81dee4 --- /dev/null +++ b/src/processing/engine/__tests__/sourceRefBindingContext.ts @@ -0,0 +1,20 @@ +import type { DataSource } from '@/src/io/import/dataSource'; +import type { SourceRefBindingContext } from '../sourceRefs'; + +const defaultDataSource: DataSource = { + type: 'uri', + uri: '/api/x/scan.nrrd', + name: 'scan.nrrd', +}; + +export const createSourceRefBindingContext = ( + overrides: Partial = {} +): SourceRefBindingContext => ({ + activeDataSource: defaultDataSource, + backgroundImageId: 'image-1', + activeSegmentGroupId: null, + segmentGroups: { orderByParent: {}, metadataByID: {} }, + hasFinishedAnnotations: false, + getDataSource: () => defaultDataSource, + ...overrides, +}); diff --git a/src/processing/engine/__tests__/sourceRefs.spec.ts b/src/processing/engine/__tests__/sourceRefs.spec.ts index ee4d604f8..b9b7aac08 100644 --- a/src/processing/engine/__tests__/sourceRefs.spec.ts +++ b/src/processing/engine/__tests__/sourceRefs.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { DataSource } from '@/src/io/import/dataSource'; import type { TaskFormModel } from '../formModel'; import { bindSourceRefs, type SourceRefBindingContext } from '../sourceRefs'; +import { createSourceRefBindingContext } from './sourceRefBindingContext'; const remoteImage: DataSource = { type: 'uri', @@ -18,19 +19,15 @@ const model = (fields: TaskFormModel['fields']): TaskFormModel => ({ const context = ( overrides: Partial = {} -): SourceRefBindingContext => ({ - activeDataSource: remoteImage, - backgroundImageId: 'image-1', - activeSegmentGroupId: null, - segmentGroups: { orderByParent: {}, metadataByID: {} }, - hasFinishedAnnotations: false, - getDataSource: () => remoteImage, - ...overrides, -}); +): SourceRefBindingContext => + createSourceRefBindingContext({ + activeDataSource: remoteImage, + getDataSource: () => remoteImage, + ...overrides, + }); // The single-group arrangement the labelmap resolver binds without a picker. const oneSegmentGroup = { - activeSegmentGroupId: 'group-1', segmentGroups: { orderByParent: { 'image-1': ['group-1'] }, metadataByID: { 'group-1': { parentImage: 'image-1' } }, @@ -56,7 +53,7 @@ describe('bindSourceRefs', () => { expect(bindings.issues).toEqual([]); }); - it('honors accepted-type order when both alternatives are available', () => { + it('binds every group when a multiple labelmap is accepted', () => { const bindings = bindSourceRefs( model([ { @@ -64,19 +61,84 @@ describe('bindSourceRefs', () => { id: 'input', accepts: ['labelmap', 'image'], required: true, + multiple: true, }, ]), context({ - activeSegmentGroupId: 'group-1', segmentGroups: { - orderByParent: { 'image-1': ['group-1'] }, - metadataByID: { 'group-1': { parentImage: 'image-1' } }, + orderByParent: { 'image-1': ['group-1', 'group-2'] }, + metadataByID: { + 'group-1': { parentImage: 'image-1' }, + 'group-2': { parentImage: 'image-1' }, + }, }, }) ); expect(bindings.types.input).toBe('labelmap'); - expect(bindings.labelmap.groups.input).toBe('group-1'); + expect(bindings.labelmap.groups.input).toEqual(['group-1', 'group-2']); + expect(bindings.issues).toEqual([]); + }); + + it('keeps a multiple labelmap plural when a union sibling takes the image', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'segs', + accepts: ['labelmap'], + required: true, + multiple: true, + }, + { + kind: 'sourceRef', + id: 'either', + accepts: ['image', 'labelmap'], + required: true, + }, + ]), + context({ + segmentGroups: { + orderByParent: { 'image-1': ['group-1', 'group-2'] }, + metadataByID: { + 'group-1': { parentImage: 'image-1' }, + 'group-2': { parentImage: 'image-1' }, + }, + }, + }) + ); + + // The union has no active group to fall back on, so it takes the image and + // leaves the plural field to bind every group. + expect(bindings.types).toEqual({ segs: 'labelmap', either: 'image' }); + expect(bindings.labelmap.groups.segs).toEqual(['group-1', 'group-2']); + expect(bindings.states.segs).toBe('bound'); + expect(bindings.issues).toEqual([]); + }); + + it('binds only the selected group for a singular labelmap', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'input', + accepts: ['labelmap'], + required: true, + }, + ]), + context({ + activeSegmentGroupId: 'group-2', + segmentGroups: { + orderByParent: { 'image-1': ['group-1', 'group-2'] }, + metadataByID: { + 'group-1': { parentImage: 'image-1' }, + 'group-2': { parentImage: 'image-1' }, + }, + }, + }) + ); + + expect(bindings.labelmap.groups.input).toEqual(['group-2']); expect(bindings.issues).toEqual([]); }); @@ -97,7 +159,6 @@ describe('bindSourceRefs', () => { }, ]), context({ - activeSegmentGroupId: 'group-1', segmentGroups: { orderByParent: { 'image-1': ['group-1'] }, metadataByID: { 'group-1': { parentImage: 'image-1' } }, @@ -120,7 +181,6 @@ describe('bindSourceRefs', () => { }, ]), context({ - activeSegmentGroupId: 'group-1', segmentGroups: { orderByParent: { 'image-1': ['group-1'] }, metadataByID: { 'group-1': { parentImage: 'image-1' } }, @@ -133,6 +193,36 @@ describe('bindSourceRefs', () => { expect(bindings.issues).toEqual([]); }); + // Staging mints a reference per group, so no group set may reach it without + // one; the parent is the background image, which is the same for every group. + it('refuses a plural labelmap whose parent image lacks provenance', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'segs', + accepts: ['labelmap'], + required: true, + multiple: true, + }, + ]), + context({ + segmentGroups: { + orderByParent: { 'image-1': ['group-1', 'group-2'] }, + metadataByID: { + 'group-1': { parentImage: 'image-1' }, + 'group-2': { parentImage: 'image-1' }, + }, + }, + getDataSource: () => undefined, + }) + ); + + expect(bindings.states.segs).toBe('no-provenance'); + expect(bindings.issues).toHaveLength(1); + expect(bindings.issues[0].message).toMatch(/not loaded from the server/i); + }); + it('walks image provenance only once', () => { let sourceReads = 0; const source = { @@ -172,7 +262,6 @@ describe('bindSourceRefs', () => { }, ]), context({ - activeSegmentGroupId: 'group-1', segmentGroups: { orderByParent: { 'image-1': ['group-1'] }, metadataByID: { 'group-1': { parentImage: 'image-1' } }, @@ -373,7 +462,7 @@ describe('bindSourceRefs — annotations', () => { seg: 'labelmap', annotations: 'annotations', }); - expect(bindings.labelmap.groups.seg).toBe('group-1'); + expect(bindings.labelmap.groups.seg).toEqual(['group-1']); expect(bindings.annotations.parameters).toEqual(['annotations']); expect(bindings.issues).toEqual([]); }); diff --git a/src/processing/engine/mintInput.ts b/src/processing/engine/mintInput.ts index 0b108d3f9..2fff4ab2f 100644 --- a/src/processing/engine/mintInput.ts +++ b/src/processing/engine/mintInput.ts @@ -86,12 +86,17 @@ export const imageInputFields = (model: TaskFormModel): SourceRefField[] => // validation issues and FileWidget renders the same text in the form body. export const bindingStateMessage = ( state: SourceRefBindingState, - noun: SourceRefNoun + noun: SourceRefNoun, + // A plural input takes every group on the active dataset, so selecting one is + // not a remedy. + multiple = false ): string | undefined => { if (state === 'no-provenance') return 'The active volume was not loaded from the server, so it cannot be used as an input.'; if (state === 'no-segment-group') - return 'Paint or select a segment group first.'; + return multiple + ? 'Paint a segment group on the active dataset first.' + : 'Paint or select a segment group first.'; if (state === 'no-annotations') return 'Place a ruler, rectangle, or polygon on the current image first.'; if (state === 'no-reference-input') @@ -133,7 +138,7 @@ export const unboundBinding = ( states: Record; issues: FormValidationIssue[]; } => { - const message = bindingStateMessage(state, noun); + const message = bindingStateMessage(state, noun, field.multiple === true); return { states: { [field.id]: state }, issues: @@ -205,15 +210,3 @@ export const bindMintedImageInputs = ( value: InputValue | null ): ImageBindingResult => bindImageFields(imageInputFields(model), activeDataSource, value); - -export const bindImageInputs = ( - model: TaskFormModel, - activeDataSource: DataSource | undefined -): ImageBindingResult => { - const fields = imageInputFields(model); - const value = - fields.length === 1 && activeDataSource - ? mintInputValue(activeDataSource, TYPE_TAG_IMAGE) - : null; - return bindImageFields(fields, activeDataSource, value); -}; diff --git a/src/processing/engine/mintLabelmap.ts b/src/processing/engine/mintLabelmap.ts index 3f848edfb..3bc884fa7 100644 --- a/src/processing/engine/mintLabelmap.ts +++ b/src/processing/engine/mintLabelmap.ts @@ -29,34 +29,35 @@ export const mintLabelmapReferenceImage = ( }; export type LabelmapResolution = - | { kind: 'resolved'; groupId: string } + | { kind: 'resolved'; groupIds: string[] } | { kind: 'unresolved' }; -export const resolveLabelmapGroup = ( +export const resolveLabelmapGroups = ( backgroundImageId: string | undefined, activeSegmentGroupId: string | null | undefined, + multiple: boolean, view: SegmentGroupView ): LabelmapResolution => { if (!backgroundImageId) return { kind: 'unresolved' }; - const belongsToBackground = (groupId: string): boolean => - view.metadataByID[groupId]?.parentImage === backgroundImageId; - - if (activeSegmentGroupId && belongsToBackground(activeSegmentGroupId)) { - return { kind: 'resolved', groupId: activeSegmentGroupId }; + const groupIds = (view.orderByParent[backgroundImageId] ?? []).filter( + (groupId) => view.metadataByID[groupId]?.parentImage === backgroundImageId + ); + if (multiple) { + return groupIds.length > 0 + ? { kind: 'resolved', groupIds } + : { kind: 'unresolved' }; } - - // No picker exists, so an ambiguous background fails closed. - const groups = view.orderByParent[backgroundImageId] ?? []; - if (groups.length === 1 && belongsToBackground(groups[0])) { - return { kind: 'resolved', groupId: groups[0] }; + if (activeSegmentGroupId && groupIds.includes(activeSegmentGroupId)) { + return { kind: 'resolved', groupIds: [activeSegmentGroupId] }; } - - return { kind: 'unresolved' }; + return groupIds.length === 1 + ? { kind: 'resolved', groupIds } + : { kind: 'unresolved' }; }; export type LabelmapBindingResult = { - groups: Record; + groups: Record; states: Record; // Caller must suppress its generic issue for these param ids. issues: FormValidationIssue[]; @@ -88,7 +89,7 @@ const bindLabelmapFields = ( } return { - groups: { [field.id]: resolution.groupId }, + groups: { [field.id]: resolution.groupIds }, states: { [field.id]: 'bound' }, issues: [], }; @@ -100,20 +101,6 @@ export const bindResolvedLabelmapInputs = ( ): LabelmapBindingResult => bindLabelmapFields(labelmapInputFields(model), resolution); -export const bindLabelmapInputs = ( - model: TaskFormModel, - backgroundImageId: string | undefined, - activeSegmentGroupId: string | null | undefined, - view: SegmentGroupView -): LabelmapBindingResult => { - const fields = labelmapInputFields(model); - const resolution = - fields.length === 1 - ? resolveLabelmapGroup(backgroundImageId, activeSegmentGroupId, view) - : { kind: 'unresolved' as const }; - return bindLabelmapFields(fields, resolution); -}; - // `format` is omitted: the staged uri already carries the extension. export const mintLabelmapValue = (uris: string[]): InputValue => ({ type: TYPE_TAG_LABELMAP, diff --git a/src/processing/engine/sourceRefs.ts b/src/processing/engine/sourceRefs.ts index b6f765f85..b5b4c85eb 100644 --- a/src/processing/engine/sourceRefs.ts +++ b/src/processing/engine/sourceRefs.ts @@ -15,7 +15,7 @@ import { import { bindResolvedLabelmapInputs, mintLabelmapReferenceImage, - resolveLabelmapGroup, + resolveLabelmapGroups, type LabelmapBindingResult, type SegmentGroupView, } from './mintLabelmap'; @@ -63,6 +63,11 @@ const acceptedTypes = (field: SourceRefField): BoundSourceRefType[] => ) ); +const once = (compute: () => T): (() => T) => { + let cached: { value: T } | undefined; + return () => (cached ??= { value: compute() }).value; +}; + const modelForType = ( model: TaskFormModel, types: Record, @@ -93,31 +98,51 @@ export const bindSourceRefs = ( acceptsImage || acceptsAnnotations ? mintInputValue(context.activeDataSource, TYPE_TAG_IMAGE) : null; - const labelmapResolution = acceptsLabelmap - ? resolveLabelmapGroup( - context.backgroundImageId, - context.activeSegmentGroupId, - context.segmentGroups - ) - : { kind: 'unresolved' as const }; - const labelmapReference = - labelmapResolution.kind === 'resolved' + // Plurality belongs to the field that binds as labelmap, which is only known + // after type resolution — and type resolution needs to know what a field + // could bind to. Both candidates are resolved up front so each step reads the + // one matching the field it is asking about. + const resolveFor = (multiple: boolean) => + acceptsLabelmap + ? resolveLabelmapGroups( + context.backgroundImageId, + context.activeSegmentGroupId, + multiple, + context.segmentGroups + ) + : { kind: 'unresolved' as const }; + const singularResolution = once(() => resolveFor(false)); + const pluralResolution = once(() => resolveFor(true)); + const resolutionFor = (field: SourceRefField) => + field.multiple === true ? pluralResolution() : singularResolution(); + // Every resolvable group has the background image as its parent, so one + // minted reference serves both pluralities; the plural resolution resolves + // whenever the singular one does. Minting walks provenance, so it is deferred + // until a caller needs it and kept for the rest of this bind. + const labelmapReference = once(() => { + const plural = pluralResolution(); + return plural.kind === 'resolved' ? mintLabelmapReferenceImage( - labelmapResolution.groupId, + plural.groupIds[0], context.segmentGroups, context.getDataSource ) : null; + }); const available = new Set(); if (imageValue) { available.add(TYPE_TAG_IMAGE); } - if (labelmapResolution.kind === 'resolved' && labelmapReference) { - available.add(TYPE_TAG_LABELMAP); - } if (context.hasFinishedAnnotations && imageValue) { available.add(TYPE_TAG_ANNOTATIONS); } + const isAvailable = ( + field: SourceRefField, + type: BoundSourceRefType + ): boolean => + type === TYPE_TAG_LABELMAP + ? resolutionFor(field).kind === 'resolved' && Boolean(labelmapReference()) + : available.has(type); const types: Record = {}; const dedicated = new Set(); @@ -130,7 +155,7 @@ export const bindSourceRefs = ( fields.forEach((field) => { const accepts = acceptedTypes(field); if (accepts.length <= 1) return; - const availableTypes = accepts.filter((type) => available.has(type)); + const availableTypes = accepts.filter((type) => isAvailable(field, type)); const selected = availableTypes.find((type) => !dedicated.has(type)) ?? availableTypes[0] ?? @@ -139,6 +164,15 @@ export const bindSourceRefs = ( if (selected) types[field.id] = selected; }); + const boundLabelmapFields = fields.filter( + (field) => types[field.id] === TYPE_TAG_LABELMAP + ); + // More than one bound field binds ambiguously whatever the resolution is. + const labelmapResolution = + boundLabelmapFields.length === 1 + ? resolutionFor(boundLabelmapFields[0]) + : singularResolution(); + const image = bindMintedImageInputs( modelForType(model, types, TYPE_TAG_IMAGE), context.activeDataSource, @@ -149,20 +183,19 @@ export const bindSourceRefs = ( labelmapResolution ); const labelmapIssues = [...labelmap.issues]; - Object.entries(labelmap.groups).forEach(([parameterId, groupId]) => { - const reference = - labelmapResolution.kind === 'resolved' && - labelmapResolution.groupId === groupId - ? labelmapReference - : null; - if (reference) return; - labelmap.states[parameterId] = 'no-provenance'; - labelmapIssues.push({ - parameter: parameterId, - message: - 'The segment group reference image was not loaded from the server, so it cannot be used as an input.', + // A param carries groups only when its resolution resolved, so the reference + // is minted here exactly when there is something to bind it to. + const boundLabelmapParams = Object.keys(labelmap.groups); + if (boundLabelmapParams.length > 0 && !labelmapReference()) { + boundLabelmapParams.forEach((parameterId) => { + labelmap.states[parameterId] = 'no-provenance'; + labelmapIssues.push({ + parameter: parameterId, + message: + 'The segment group reference image was not loaded from the server, so it cannot be used as an input.', + }); }); - }); + } const annotations = bindAnnotationsInputs( modelForType(model, types, TYPE_TAG_ANNOTATIONS), From 3fbbba8d1135cb414cfae9e9c03f876648f65822 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 15 Aug 2026 13:31:49 -0400 Subject: [PATCH 2/2] refactor(processing): extract job display and input staging from JobsModule JobsModule.vue had accumulated three jobs beyond orchestration: pure display-string formatting, store-reading input staging, and the binding/validation glue that is actually the component's role. Split the first two out: - engine/jobDisplay.ts: pure formatting for submitted-job display and bound source-ref names. Group names are resolved into the context by the caller, and the previously duplicated source-ref dispatch in boundSourceRefName/formatProcessingValue is unified with per-type fallbacks. - composables/useInputStaging.ts: active-image input reads and the Run-time staging of segment groups and annotations, including the annotation tools view. stageLabelmapInputs now requires its bindings instead of defaulting to a fresh bind. No behavior change. --- src/processing/components/JobsModule.vue | 376 ++---------------- src/processing/composables/useInputStaging.ts | 238 +++++++++++ src/processing/engine/jobDisplay.ts | 124 ++++++ 3 files changed, 400 insertions(+), 338 deletions(-) create mode 100644 src/processing/composables/useInputStaging.ts create mode 100644 src/processing/engine/jobDisplay.ts diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index c7bd60ded..86709ff9f 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -126,62 +126,31 @@ import { useCropStore } from '@/src/store/tools/crop'; import type { ProcessingProvider, ProcessingValue, - SubmittedJobDisplay, - SubmittedJobParameterDisplay, TaskSummary, } from '@/src/processing/types'; import { buildTaskFormModel, initialFormValues, validateFormValues, - fieldLabel, type TaskFormModel, type FormValidationIssue, } from '@/src/processing/engine/formModel'; import { type SourceRefBindingState } from '@/src/processing/engine/mintInput'; -import { - mintLabelmapValue, - mintLabelmapReferenceImage, - type SegmentGroupView, -} from '@/src/processing/engine/mintLabelmap'; -import { mintInputValue } from '@/src/processing/engine/mintInput'; -import { mintAnnotationsValue } from '@/src/processing/engine/mintAnnotations'; -import { - annotationToolsViewCount, - annotationsFileCount, - encodeAnnotationsFile, - hasTwoPoints, - isEncodablePolygon, - type AnnotationKindView, - type AnnotationToolsView, - type PolygonToolView, - type TwoPointToolView, -} from '@/src/processing/engine/annotationsWire'; -import { annotationToolStore } from '@/src/processing/annotationKinds'; import { bindSourceRefs, type BoundSourceRefType, type SourceRefBindings, } from '@/src/processing/engine/sourceRefs'; +import { + buildJobDisplay, + buildSourceRefNames, + type JobDisplayContext, +} from '@/src/processing/engine/jobDisplay'; 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 { useMessageStore } from '@/src/store/messages'; -import { writeSegmentation } from '@/src/io/readWriteImage'; -import { getDataSourceName } from '@/src/io/import/dataSource'; -import type { - AnnotationToolKind, - AnnotationsFile, - InputValue, - VolViewTaskParameter, -} from '@/backend-contract'; -import { - ANNOTATIONS_FILE_EXTENSION, - TYPE_TAG_ANNOTATIONS, - TYPE_TAG_LABELMAP, -} from '@/backend-contract'; -import type { AnnotationTool } from '@/src/types/annotation-tool'; -import { stripExtension } from '@/src/utils/path'; import TaskPicker from './TaskPicker.vue'; import TaskForm from './TaskForm.vue'; @@ -196,6 +165,16 @@ const paintStore = usePaintToolStore(); const segmentGroupStore = useSegmentGroupStore(); const messageStore = useMessageStore(); +const { + activeDataSource, + activeImageName, + segmentGroupView, + finishedAnnotationCount, + captureAnnotationsPayload, + stageLabelmapInputs, + stageAnnotationInputs, +} = useInputStaging(); + const openPanels = ref(['run', 'jobs']); const providerItems = computed(() => @@ -373,7 +352,11 @@ async function onSubmit(values: Record) { } // Display formatting reads live active image and segment-group state, so it // must render before the staging await. - const display = buildJobDisplay(model, finalValues); + const display = buildJobDisplay( + model, + jobDisplayContext(bindings), + finalValues + ); // Same reason, and it also outlives the staging awaits below: the annotations // file, its name, and its reference image all come from the active image, // which the user may switch while an upload is in flight. Only encoded when @@ -405,7 +388,7 @@ async function onSubmit(values: Record) { try { staged = await Promise.all([ stage('Failed to stage segment group input', () => - stageLabelmapInputs(submitProvider, model, bindings) + stageLabelmapInputs(submitProvider, bindings) ), stage('Failed to stage annotations input', () => stageAnnotationInputs(submitProvider, bindings, annotations) @@ -459,69 +442,6 @@ function applyBoundsBindings( return next; } -function activeDataSource() { - return datasetStore.getDataSource(currentImageID.value); -} - -function activeImageName(): string | undefined { - const id = currentImageID.value; - return ( - imageCache.getImageMetadata(id)?.name ?? - getDataSourceName(activeDataSource()) ?? - undefined - ); -} - -function segmentGroupView(): SegmentGroupView { - return { - orderByParent: segmentGroupStore.orderByParent, - metadataByID: segmentGroupStore.metadataByID, - }; -} - -function labelmapReferenceImage(segmentGroupId: string): InputValue | null { - return mintLabelmapReferenceImage( - segmentGroupId, - segmentGroupView(), - (imageId) => datasetStore.getDataSource(imageId) - ); -} - -// Tool lists are per image, and so is the staged annotations file: only the -// active image's finished tools are ever an input. -function onActiveImage( - tools: readonly T[] -): T[] { - const id = currentImageID.value; - return id ? tools.filter((tool) => tool.imageID === id) : []; -} - -// The three stores are independent, so each keeps its own label namespace; the -// encoder prunes and re-keys them by name. -const annotationToolsView = computed(() => { - const kindView = ( - kind: AnnotationToolKind, - hasGeometry: (tool: U) => tool is U & T - ): AnnotationKindView => { - const store = annotationToolStore(kind); - return { - tools: onActiveImage(store.finishedTools).filter(hasGeometry), - labels: store.labels, - }; - }; - return { - rulers: kindView('rulers', hasTwoPoints), - rectangles: kindView('rectangles', hasTwoPoints), - polygons: kindView('polygons', isEncodablePolygon), - }; -}); - -// Computed, not a function call: placing a tool churns the stores every drag -// frame, and an unchanged count stops the invalidation there. -const finishedAnnotationCount = computed(() => - annotationToolsViewCount(annotationToolsView.value) -); - function activeSourceBindings(model: TaskFormModel): SourceRefBindings { return bindSourceRefs(model, { activeDataSource: activeDataSource(), @@ -582,255 +502,35 @@ function refreshValidation( return validation.issues; } -// Returns only the parameters it staged, so the caller owns the merge. -// -// The literal 'seg.nrrd' name is required for segment names and colors to be -// embedded in the serialized output. -async function stageSegmentGroupInput( - p: ProcessingProvider, - segmentGroupId: string, - // Group names are not unique, so a plural parameter numbers its files to keep - // them distinguishable in the job folder. - ordinal?: number -): Promise { - const metadata = segmentGroupStore.metadataByID[segmentGroupId]; - const labelmap = segmentGroupStore.dataIndex[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 suffix = ordinal === undefined ? '' : `-${ordinal}`; - return p.stageInput({ - file: new Blob([serialized]), - descriptor: { - type: TYPE_TAG_LABELMAP, - name: `${metadata.name}${suffix}.seg.nrrd`, - referenceImage: { - ...referenceImage, - type: 'image', - }, - }, - }); -} - -async function stageLabelmapInputs( - p: ProcessingProvider, - model: TaskFormModel, - bindings: SourceRefBindings = activeSourceBindings(model) -): Promise> { - const targets = Object.entries(bindings.labelmap.groups); - if (targets.length === 0) return {}; - - const staged = await Promise.all( - targets.map(async ([parameterId, segmentGroupIds]) => { - const uris = ( - await Promise.all( - segmentGroupIds.map((groupId, index) => - stageSegmentGroupInput( - p, - groupId, - segmentGroupIds.length > 1 ? index + 1 : undefined - ) - ) - ) - ).flat(); - return [parameterId, mintLabelmapValue(uris)] as const; - }) - ); - return Object.fromEntries(staged); -} - -// The extension is what the CLI spec and the backend both match on, so the base -// name is the active image's without its own — compound extensions included, so -// `scan.nii.gz` stages as `scan.annotations.json`. -function annotationsFileName(): string { - const name = activeImageName() ?? 'image'; - return `${stripExtension(name)}${ANNOTATIONS_FILE_EXTENSION}`; -} - -// Everything the annotations file is made of, read off the stores in one -// synchronous pass so staging never mixes two images' state. -type AnnotationsPayload = { - file: AnnotationsFile; - name: string; - referenceImage: InputValue | null; -}; - -function captureAnnotationsPayload(): AnnotationsPayload { - return { - file: encodeAnnotationsFile(annotationToolsView.value), - name: annotationsFileName(), - referenceImage: mintInputValue(activeDataSource()), - }; -} - -// One file per bound parameter, holding every finished tool the active image -// had at Run. The image is its own reference image, so a volume without server -// provenance never gets here — the binder already refused it. -async function stageAnnotationInputs( - p: ProcessingProvider, - bindings: SourceRefBindings, - payload: AnnotationsPayload | null -): Promise> { - const [parameterId] = bindings.annotations.parameters; - if (!parameterId || !payload) return {}; - const { file, name, referenceImage } = payload; - - if (!referenceImage) { - throw new Error('The active image has no server provenance'); - } - // The binder validated a live count; this is the encoded file's own count, so - // a tool deleted between validation and Run cannot stage an empty file. - if (annotationsFileCount(file) === 0) { - throw new Error('The active image has no finished annotations'); - } - - const uris = await p.stageInput({ - file: new Blob([JSON.stringify(file)], { type: 'application/json' }), - descriptor: { - type: TYPE_TAG_ANNOTATIONS, - name, - referenceImage: { - ...referenceImage, - type: 'image', - }, - }, - }); - return { - [parameterId]: mintAnnotationsValue(uris), - }; -} - -type SourceRefContext = { - labelmapGroups: Record; - types: Record; - imageName: string | undefined; - annotationCount: number; -}; - // Resolved once per display pass: each binding re-runs a full field scan and // group resolution, so per-field resolution would redo identical work. -function sourceRefContext(model: TaskFormModel): SourceRefContext { - const bindings = activeSourceBindings(model); +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 ?? + 'unnamed segment group' + ), + ]) + ); return { - labelmapGroups: bindings.labelmap.groups, + labelmapNames, types: bindings.types, imageName: activeImageName(), annotationCount: finishedAnnotationCount.value, }; } -function boundLabelmapName( - refs: SourceRefContext, - parameterId: string -): string | undefined { - const groupIds = refs.labelmapGroups[parameterId] ?? []; - const names = groupIds.map( - (groupId) => - segmentGroupStore.metadataByID[groupId]?.name ?? 'unnamed segment group' - ); - return names.length > 0 ? names.join(', ') : undefined; -} - -// The bound value is a whole set of tools rather than one named resource, so -// the count is the identifying part. -function boundAnnotationsName(refs: SourceRefContext): string { - const noun = refs.annotationCount === 1 ? 'annotation' : 'annotations'; - const count = `${refs.annotationCount} ${noun}`; - return refs.imageName ? `${count} on ${refs.imageName}` : count; -} - -function boundSourceRefName( - refs: SourceRefContext, - parameterId: string -): string | undefined { - const type = refs.types[parameterId]; - if (type === TYPE_TAG_LABELMAP) return boundLabelmapName(refs, parameterId); - if (type === TYPE_TAG_ANNOTATIONS) return boundAnnotationsName(refs); - return refs.imageName; -} - const sourceRefNames = computed(() => { const model = taskModel.value; if (!model) return {}; - const refs = sourceRefContext(model); - const names: Record = {}; - model.fields.forEach((field) => { - if (field.kind !== 'sourceRef') return; - const name = boundSourceRefName(refs, field.id); - if (name) names[field.id] = name; - }); - return names; -}); - -function formatProcessingValue( - refs: SourceRefContext, - field: VolViewTaskParameter, - value: ProcessingValue -): string { - if (field.kind === 'sourceRef') { - if (refs.types[field.id] === TYPE_TAG_LABELMAP) { - // A bound param always names its groups, so the fallback is the optional - // param that bound nothing. - return boundLabelmapName(refs, field.id) ?? 'not provided'; - } - if (refs.types[field.id] === TYPE_TAG_ANNOTATIONS) { - return boundAnnotationsName(refs); - } - return refs.imageName ?? 'active dataset'; - } - if (field.kind === 'bounds') { - return Array.isArray(value) && value.length > 0 - ? value.map((n) => (typeof n === 'number' ? n.toFixed(1) : n)).join(', ') - : 'not set'; - } - if (typeof value === 'boolean') return value ? 'true' : 'false'; - if (Array.isArray(value)) return value.join(', '); - if (value && typeof value === 'object') { - const input = value as InputValue; - return input.type; - } - if (value === null || value === undefined || value === '') return 'not set'; - return String(value); -} - -function isSummaryParameter( - field: VolViewTaskParameter, - value: ProcessingValue -): boolean { - if (field.kind === 'sourceRef' || field.kind === 'bounds') return false; - if (value === null || value === undefined || value === '') return false; - if (Array.isArray(value) && value.length === 0) return false; - return true; -} - -function buildJobDisplay( - model: TaskFormModel, - values: Record -): SubmittedJobDisplay { - const refs = sourceRefContext(model); - let summaryCount = 0; - const parameters: SubmittedJobParameterDisplay[] = model.fields.map( - (field) => { - const value = values[field.id]; - const summary = summaryCount < 2 && isSummaryParameter(field, value); - if (summary) summaryCount += 1; - return { - id: field.id, - label: fieldLabel(field), - value: formatProcessingValue(refs, field, value), - ...(summary ? { summary } : {}), - }; - } + return buildSourceRefNames( + model, + jobDisplayContext(activeSourceBindings(model)) ); - const inputName = refs.imageName; - return { - taskTitle: model.title, - ...(inputName ? { inputName } : {}), - parameters, - }; -} +}); // Debounced: dragging a crop handle mutates the crop planes every frame, and // each rebind walks provenance and re-validates the whole form. diff --git a/src/processing/composables/useInputStaging.ts b/src/processing/composables/useInputStaging.ts new file mode 100644 index 000000000..d3173ffe8 --- /dev/null +++ b/src/processing/composables/useInputStaging.ts @@ -0,0 +1,238 @@ +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 { writeSegmentation } from '@/src/io/readWriteImage'; +import { getDataSourceName } from '@/src/io/import/dataSource'; +import { stripExtension } from '@/src/utils/path'; +import type { + AnnotationToolKind, + AnnotationsFile, + InputValue, +} from '@/backend-contract'; +import { + ANNOTATIONS_FILE_EXTENSION, + TYPE_TAG_ANNOTATIONS, + TYPE_TAG_LABELMAP, +} from '@/backend-contract'; +import type { AnnotationTool } from '@/src/types/annotation-tool'; +import type { + ProcessingProvider, + ProcessingValue, +} from '@/src/processing/types'; +import { + mintLabelmapValue, + mintLabelmapReferenceImage, + type SegmentGroupView, +} from '@/src/processing/engine/mintLabelmap'; +import { mintInputValue } from '@/src/processing/engine/mintInput'; +import { mintAnnotationsValue } from '@/src/processing/engine/mintAnnotations'; +import { + annotationToolsViewCount, + annotationsFileCount, + encodeAnnotationsFile, + hasTwoPoints, + isEncodablePolygon, + type AnnotationKindView, + type AnnotationToolsView, + type PolygonToolView, + type TwoPointToolView, +} from '@/src/processing/engine/annotationsWire'; +import { annotationToolStore } from '@/src/processing/annotationKinds'; +import type { SourceRefBindings } from '@/src/processing/engine/sourceRefs'; + +// Everything the annotations file is made of, read off the stores in one +// synchronous pass so staging never mixes two images' state. +export type AnnotationsPayload = { + file: AnnotationsFile; + name: string; + referenceImage: InputValue | null; +}; + +// Reads the active image's inputs off the stores and stages them with a +// provider at Run. Values earn URIs only here: neither the labelmap nor the +// annotations file has server provenance of its own before staging. +export function useInputStaging() { + const { currentImageID } = useCurrentImage('global'); + const imageCache = useImageCacheStore(); + const datasetStore = useDatasetStore(); + const segmentGroupStore = useSegmentGroupStore(); + + const activeDataSource = () => + datasetStore.getDataSource(currentImageID.value); + + const activeImageName = (): string | undefined => { + const id = currentImageID.value; + return ( + imageCache.getImageMetadata(id)?.name ?? + getDataSourceName(activeDataSource()) ?? + undefined + ); + }; + + const segmentGroupView = (): SegmentGroupView => ({ + orderByParent: segmentGroupStore.orderByParent, + metadataByID: segmentGroupStore.metadataByID, + }); + + const labelmapReferenceImage = (segmentGroupId: string): InputValue | null => + mintLabelmapReferenceImage(segmentGroupId, segmentGroupView(), (imageId) => + datasetStore.getDataSource(imageId) + ); + + // Tool lists are per image, and so is the staged annotations file: only the + // active image's finished tools are ever an input. + const onActiveImage = ( + tools: readonly T[] + ): T[] => { + const id = currentImageID.value; + return id ? tools.filter((tool) => tool.imageID === id) : []; + }; + + // The three stores are independent, so each keeps its own label namespace; + // the encoder prunes and re-keys them by name. + const annotationToolsView = computed(() => { + const kindView = ( + kind: AnnotationToolKind, + hasGeometry: (tool: U) => tool is U & T + ): AnnotationKindView => { + const store = annotationToolStore(kind); + return { + tools: onActiveImage(store.finishedTools).filter(hasGeometry), + labels: store.labels, + }; + }; + return { + rulers: kindView('rulers', hasTwoPoints), + rectangles: kindView('rectangles', hasTwoPoints), + polygons: kindView('polygons', isEncodablePolygon), + }; + }); + + // Computed, not a function call: placing a tool churns the stores every drag + // frame, and an unchanged count stops the invalidation there. + const finishedAnnotationCount = computed(() => + annotationToolsViewCount(annotationToolsView.value) + ); + + // Returns only the parameters it staged, so the caller owns the merge. + // + // The literal 'seg.nrrd' name is required for segment names and colors to be + // embedded in the serialized output. + const stageSegmentGroupInput = async ( + p: ProcessingProvider, + segmentGroupId: string, + // Group names are not unique, so a plural parameter numbers its files to + // keep them distinguishable in the job folder. + ordinal?: number + ): Promise => { + const metadata = segmentGroupStore.metadataByID[segmentGroupId]; + const labelmap = segmentGroupStore.dataIndex[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 suffix = ordinal === undefined ? '' : `-${ordinal}`; + return p.stageInput({ + file: new Blob([serialized]), + descriptor: { + type: TYPE_TAG_LABELMAP, + name: `${metadata.name}${suffix}.seg.nrrd`, + referenceImage: { + ...referenceImage, + type: 'image', + }, + }, + }); + }; + + const stageLabelmapInputs = async ( + p: ProcessingProvider, + bindings: SourceRefBindings + ): Promise> => { + const targets = Object.entries(bindings.labelmap.groups); + if (targets.length === 0) return {}; + + const staged = await Promise.all( + targets.map(async ([parameterId, segmentGroupIds]) => { + const uris = ( + await Promise.all( + segmentGroupIds.map((groupId, index) => + stageSegmentGroupInput( + p, + groupId, + segmentGroupIds.length > 1 ? index + 1 : undefined + ) + ) + ) + ).flat(); + return [parameterId, mintLabelmapValue(uris)] as const; + }) + ); + return Object.fromEntries(staged); + }; + + // The extension is what the CLI spec and the backend both match on, so the + // base name is the active image's without its own — compound extensions + // included, so `scan.nii.gz` stages as `scan.annotations.json`. + const annotationsFileName = (): string => { + const name = activeImageName() ?? 'image'; + return `${stripExtension(name)}${ANNOTATIONS_FILE_EXTENSION}`; + }; + + const captureAnnotationsPayload = (): AnnotationsPayload => ({ + file: encodeAnnotationsFile(annotationToolsView.value), + name: annotationsFileName(), + referenceImage: mintInputValue(activeDataSource()), + }); + + // One file per bound parameter, holding every finished tool the active image + // had at Run. The image is its own reference image, so a volume without + // server provenance never gets here — the binder already refused it. + const stageAnnotationInputs = async ( + p: ProcessingProvider, + bindings: SourceRefBindings, + payload: AnnotationsPayload | null + ): Promise> => { + const [parameterId] = bindings.annotations.parameters; + if (!parameterId || !payload) return {}; + const { file, name, referenceImage } = payload; + + if (!referenceImage) { + throw new Error('The active image has no server provenance'); + } + // The binder validated a live count; this is the encoded file's own count, + // so a tool deleted between validation and Run cannot stage an empty file. + if (annotationsFileCount(file) === 0) { + throw new Error('The active image has no finished annotations'); + } + + const uris = await p.stageInput({ + file: new Blob([JSON.stringify(file)], { type: 'application/json' }), + descriptor: { + type: TYPE_TAG_ANNOTATIONS, + name, + referenceImage: { + ...referenceImage, + type: 'image', + }, + }, + }); + return { + [parameterId]: mintAnnotationsValue(uris), + }; + }; + + return { + activeDataSource, + activeImageName, + segmentGroupView, + finishedAnnotationCount, + captureAnnotationsPayload, + stageLabelmapInputs, + stageAnnotationInputs, + }; +} diff --git a/src/processing/engine/jobDisplay.ts b/src/processing/engine/jobDisplay.ts new file mode 100644 index 000000000..ce72904ac --- /dev/null +++ b/src/processing/engine/jobDisplay.ts @@ -0,0 +1,124 @@ +import type { InputValue, VolViewTaskParameter } from '@/backend-contract'; +import { TYPE_TAG_ANNOTATIONS, TYPE_TAG_LABELMAP } from '@/backend-contract'; +import type { + ProcessingValue, + SubmittedJobDisplay, + SubmittedJobParameterDisplay, +} from '@/src/processing/types'; +import { fieldLabel, type TaskFormModel } from './formModel'; +import type { BoundSourceRefType } from './sourceRefs'; + +// Everything the display strings are made of, resolved by the caller in one +// synchronous pass so formatting stays pure. +export type JobDisplayContext = { + // Parameter id → display names of the segment groups bound to it. + labelmapNames: Record; + types: Record; + imageName: string | undefined; + annotationCount: number; +}; + +const boundLabelmapName = ( + ctx: JobDisplayContext, + parameterId: string +): string | undefined => { + const names = ctx.labelmapNames[parameterId] ?? []; + return names.length > 0 ? names.join(', ') : undefined; +}; + +// The bound value is a whole set of tools rather than one named resource, so +// the count is the identifying part. +const boundAnnotationsName = (ctx: JobDisplayContext): string => { + const noun = ctx.annotationCount === 1 ? 'annotation' : 'annotations'; + const count = `${ctx.annotationCount} ${noun}`; + return ctx.imageName ? `${count} on ${ctx.imageName}` : count; +}; + +const boundSourceRefName = ( + ctx: JobDisplayContext, + parameterId: string +): string | undefined => { + const type = ctx.types[parameterId]; + if (type === TYPE_TAG_LABELMAP) return boundLabelmapName(ctx, parameterId); + if (type === TYPE_TAG_ANNOTATIONS) return boundAnnotationsName(ctx); + return ctx.imageName; +}; + +export const buildSourceRefNames = ( + model: TaskFormModel, + ctx: JobDisplayContext +): Record => { + const names: Record = {}; + model.fields.forEach((field) => { + if (field.kind !== 'sourceRef') return; + const name = boundSourceRefName(ctx, field.id); + if (name) names[field.id] = name; + }); + return names; +}; + +export const formatProcessingValue = ( + ctx: JobDisplayContext, + field: VolViewTaskParameter, + value: ProcessingValue +): string => { + if (field.kind === 'sourceRef') { + // A bound labelmap param always names its groups, so the fallback is the + // optional param that bound nothing. + const fallback = + ctx.types[field.id] === TYPE_TAG_LABELMAP + ? 'not provided' + : 'active dataset'; + return boundSourceRefName(ctx, field.id) ?? fallback; + } + if (field.kind === 'bounds') { + return Array.isArray(value) && value.length > 0 + ? value.map((n) => (typeof n === 'number' ? n.toFixed(1) : n)).join(', ') + : 'not set'; + } + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (Array.isArray(value)) return value.join(', '); + if (value && typeof value === 'object') { + const input = value as InputValue; + return input.type; + } + if (value === null || value === undefined || value === '') return 'not set'; + return String(value); +}; + +const isSummaryParameter = ( + field: VolViewTaskParameter, + value: ProcessingValue +): boolean => { + if (field.kind === 'sourceRef' || field.kind === 'bounds') return false; + if (value === null || value === undefined || value === '') return false; + if (Array.isArray(value) && value.length === 0) return false; + return true; +}; + +export const buildJobDisplay = ( + model: TaskFormModel, + ctx: JobDisplayContext, + values: Record +): SubmittedJobDisplay => { + let summaryCount = 0; + const parameters: SubmittedJobParameterDisplay[] = model.fields.map( + (field) => { + const value = values[field.id]; + const summary = summaryCount < 2 && isSummaryParameter(field, value); + if (summary) summaryCount += 1; + return { + id: field.id, + label: fieldLabel(field), + value: formatProcessingValue(ctx, field, value), + ...(summary ? { summary } : {}), + }; + } + ); + const inputName = ctx.imageName; + return { + taskTitle: model.title, + ...(inputName ? { inputName } : {}), + parameters, + }; +};