diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index 3b922115c..c9b2ee04e 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 takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.", + "type": "boolean" } }, "required": [ diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json index 2ea9d51ed..9e08b404f 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 takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.", + "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..0097d72bc 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 takes more than one value: the client sends one staged file per value, listed in `uris` in selection order. When absent or false, it takes a single value. Only labelmap source refs bind plurally today — every group whose parent is the active dataset, in store order; on other type tags the flag has no effect yet.' + ), }); const boundsParam = z.object({ diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index fd85cc802..a01790843 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -121,67 +121,35 @@ import { getErrorDetail, ensureError } from '@/src/utils'; import { useProcessingJobsStore } from '@/src/processing/store'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { useImageCacheStore } from '@/src/store/image-cache'; -import { useDatasetStore } from '@/src/store/datasets'; 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'; @@ -190,12 +158,20 @@ import JobList from './JobList.vue'; const providers = useProcessingJobsStore(); const { currentImageID } = useCurrentImage('global'); const imageCache = useImageCacheStore(); -const datasetStore = useDatasetStore(); const cropStore = useCropStore(); const paintStore = usePaintToolStore(); const segmentGroupStore = useSegmentGroupStore(); const messageStore = useMessageStore(); +const { + activeImageName, + finishedAnnotationCount, + sourceRefContext, + captureAnnotationsPayload, + stageLabelmapInputs, + stageAnnotationInputs, +} = useInputStaging(); + const openPanels = ref(['run', 'jobs']); const providerItems = computed(() => @@ -373,7 +349,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 +385,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,78 +439,8 @@ 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(), - backgroundImageId: currentImageID.value ?? undefined, - activeSegmentGroupId: paintStore.activeSegmentGroupID, - segmentGroups: segmentGroupView(), - hasFinishedAnnotations: finishedAnnotationCount.value > 0, - getDataSource: (imageId) => datasetStore.getDataSource(imageId), - }); + return bindSourceRefs(model, sourceRefContext()); } // Neither the labelmap nor the annotations value is set here: neither has @@ -582,237 +492,31 @@ 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 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, 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', - }, - }, - }); - 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), - }; -} - -// Binding can fall back to the current image's sole group, so reading -// `paintStore.activeSegmentGroupID` directly would mislabel the job. -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), + ]) + ); return { - labelmapGroups: bindings.labelmap.groups, + labelmapNames, types: bindings.types, imageName: activeImageName(), annotationCount: finishedAnnotationCount.value, }; } -function boundLabelmapName( - refs: SourceRefContext, - parameterId: string -): string | undefined { - const groupId = refs.labelmapGroups[parameterId]; - return groupId ? segmentGroupStore.metadataByID[groupId]?.name : 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) { - return boundLabelmapName(refs, field.id) ?? 'bound segment group'; - } - 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/components/__tests__/JobsModule.spec.ts b/src/processing/components/__tests__/JobsModule.spec.ts index c97c97c75..6664abe13 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 => ({ @@ -64,6 +77,24 @@ const registerFake = ( store.registerProviderConfig(provider.config); }; +// Auto-stubs drop slot content, hiding the panel children. +const slotStub = { template: '
' }; + +const mountJobsModule = (pinia: ReturnType) => + shallowMount(JobsModule, { + // v-select's auto-stub warns on getter-only props. + 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, + }, + }, + }); + type JobsVm = { selectedProviderId: string | null; tasks: TaskSummary[]; @@ -85,23 +116,7 @@ describe('JobsModule — race-free provider/task selection', () => { setActivePinia(pinia); }); - // Auto-stubs drop slot content, hiding the panel children. - const slotStub = { template: '
' }; - - const mount = () => - shallowMount(JobsModule, { - // v-select's auto-stub warns on getter-only props. - 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 mount = () => mountJobsModule(pinia); it('commits only the winning provider’s tasks when the stale one resolves last', async () => { const a = makeProvider('A'); @@ -407,3 +422,189 @@ 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 mount = () => mountJobsModule(pinia); + + 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..e36196476 100644 --- a/src/processing/components/widgets/FileWidget.vue +++ b/src/processing/components/widgets/FileWidget.vue @@ -35,14 +35,20 @@ const props = defineProps<{ }>(); // How each bound kind names itself in the key column and the noun the binding -// sentences use. -const KINDS: Record< - string, - { icon: string; caption: string; noun: SourceRefNoun } -> = { +// sentences use. `pluralCaption` is how a parameter taking more than one value +// names itself; kinds without one do not bind plurally. +type KindVocabulary = { + icon: string; + caption: string; + pluralCaption?: string; + noun: SourceRefNoun; +}; + +const KINDS: Record = { [TYPE_TAG_LABELMAP]: { icon: 'mdi-brush-outline', caption: 'Active segment group', + pluralCaption: 'Segment groups on active dataset', noun: 'segment group', }, [TYPE_TAG_ANNOTATIONS]: { @@ -52,12 +58,16 @@ const KINDS: Record< }, }; -const IMAGE_KIND = { +const IMAGE_KIND: KindVocabulary = { icon: 'mdi-image-outline', caption: 'Active dataset', - noun: 'image' as SourceRefNoun, + noun: 'image', }; +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 +76,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; + const caption = + (multiple.value ? resolved.pluralCaption : undefined) ?? resolved.caption; + return { ...resolved, caption }; }); const OPTIONAL_UNBOUND_STATES = new Set([ @@ -88,7 +101,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/composables/useInputStaging.ts b/src/processing/composables/useInputStaging.ts new file mode 100644 index 000000000..29baa3ccb --- /dev/null +++ b/src/processing/composables/useInputStaging.ts @@ -0,0 +1,252 @@ +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, + stagedLabelmapFileNames, + 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 { + SourceRefBindingContext, + SourceRefBindings, +} from '@/src/processing/engine/sourceRefs'; +import { usePaintToolStore } from '@/src/store/tools/paint'; + +// 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 paintStore = usePaintToolStore(); + + 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) + ); + + // The stores this composable already holds are exactly what the binder reads, + // so the context is assembled here rather than re-wiring them at the caller. + const sourceRefContext = (): SourceRefBindingContext => ({ + activeDataSource: activeDataSource(), + backgroundImageId: currentImageID.value ?? undefined, + activeSegmentGroupId: paintStore.activeSegmentGroupID, + segmentGroups: segmentGroupView(), + hasFinishedAnnotations: finishedAnnotationCount.value > 0, + getDataSource: (imageId) => datasetStore.getDataSource(imageId), + }); + + // 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, + fileName: string + ): 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); + return p.stageInput({ + file: new Blob([serialized]), + descriptor: { + type: TYPE_TAG_LABELMAP, + name: fileName, + referenceImage: { + ...referenceImage, + type: 'image', + }, + }, + }); + }; + + // Returns only the parameters it staged, so the caller owns the merge. + // + // Staged one at a time rather than fanned out: serialization deep-copies the + // whole voxel buffer, so a concurrent map would hold every group's copy at + // once, and itk-wasm queues the writes on a single shared worker regardless. + const stageLabelmapInputs = async ( + p: ProcessingProvider, + bindings: SourceRefBindings + ): Promise> => { + const staged: Record = {}; + for (const [parameterId, segmentGroupIds] of Object.entries( + bindings.labelmap.groups + )) { + const fileNames = stagedLabelmapFileNames( + segmentGroupIds.map( + (groupId) => segmentGroupStore.metadataByID[groupId].name + ) + ); + const uris: string[] = []; + for (const [index, groupId] of segmentGroupIds.entries()) { + uris.push( + ...(await stageSegmentGroupInput(p, groupId, fileNames[index])) + ); + } + staged[parameterId] = mintLabelmapValue(uris); + } + return 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 { + activeImageName, + finishedAnnotationCount, + sourceRefContext, + captureAnnotationsPayload, + stageLabelmapInputs, + stageAnnotationInputs, + }; +} 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..735d62dd3 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,15 @@ const remoteFile = (uri: string): DataSource => ({ name: 'scan.nrrd', }); +const context = ( + overrides: Partial = {} +): SourceRefBindingContext => + createSourceRefBindingContext({ + backgroundImageId: 'bg', + segmentGroups: viewOf({}), + ...overrides, + }); + describe('labelmapInputFields', () => { it('selects sourceRef params that accept a labelmap', () => { const model: TaskFormModel = { @@ -73,78 +82,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 +154,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 +220,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 +246,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/jobDisplay.ts b/src/processing/engine/jobDisplay.ts new file mode 100644 index 000000000..b4ca9bff0 --- /dev/null +++ b/src/processing/engine/jobDisplay.ts @@ -0,0 +1,123 @@ +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 { plural } from '@/src/utils'; +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; +}; + +// A parameter carries groups only when its resolution resolved, and a resolved +// resolution always names at least one group. +const boundLabelmapName = ( + ctx: JobDisplayContext, + parameterId: string +): string | undefined => ctx.labelmapNames[parameterId]?.join(', '); + +// 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 count = `${ctx.annotationCount} ${plural(ctx.annotationCount, 'annotation')}`; + 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, + }; +}; 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..70e368c1e 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,22 +101,17 @@ 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, uris, }); + +// The `.seg.nrrd` extension is what makes the writer embed segment names and +// colors. Group names are not unique, so a parameter taking more than one +// numbers its files (1-based) to keep them distinguishable in the job folder. +export const stagedLabelmapFileNames = (groupNames: string[]): string[] => + groupNames.map( + (name, index) => + `${name}${groupNames.length > 1 ? `-${index + 1}` : ''}.seg.nrrd` + ); diff --git a/src/processing/engine/sourceRefs.ts b/src/processing/engine/sourceRefs.ts index b6f765f85..bdecb42d5 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'; @@ -93,17 +93,30 @@ 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 }; + // 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 = resolveFor(false); + const pluralResolution = 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. const labelmapReference = - labelmapResolution.kind === 'resolved' + pluralResolution.kind === 'resolved' ? mintLabelmapReferenceImage( - labelmapResolution.groupId, + pluralResolution.groupIds[0], context.segmentGroups, context.getDataSource ) @@ -112,12 +125,16 @@ export const bindSourceRefs = ( 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 +147,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 +156,16 @@ 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, so + // the binder discards it there. + const labelmapResolution = + boundLabelmapFields.length === 1 + ? resolutionFor(boundLabelmapFields[0]) + : { kind: 'unresolved' as const }; + const image = bindMintedImageInputs( modelForType(model, types, TYPE_TAG_IMAGE), context.activeDataSource, @@ -149,20 +176,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 a missing + // reference here means the groups themselves cannot be staged. + 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),