Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions backend-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,25 @@ Two versions on separate clocks:
`info.version`, kept in lockstep by `processing/__tests__/openapi.spec.ts`.
Versions this package as a published thing.
- **Shape versions**: `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) and
the task-spec `specVersion`. These version the wire vocabulary for additive
compatibility negotiation.
the task-spec `specVersion`. These name the shape of the wire vocabulary in
the generated OpenAPI description and in release notes. Neither travels on
the wire, so neither is negotiated: additive compatibility rests on both
sides failing open on a value they do not know.

### Result instruction rollout

Contract artifact 0.3.0 uses intent vocabulary 3 and names segmentation import
`import-segmentation`. A current client still reads the earlier name,
`add-segment-group`, as the same instruction (`LEGACY_RESULT_INTENT_NAMES` in
`processing/wire.ts`), so a producer may move to the new name after the client.
The reverse order does not hold: an older client treats `import-segmentation`
as an ordinary result and will not apply its segmentation automatically.
Update Girder's pinned VolView package before the producer emits the new name.

This vocabulary change does not change task-spec versions or saved-session
schemas. Girder projects stored job outputs into current instructions when
results are requested; stored output references and mask provenance keep their
identities.

## Regenerating

Expand Down
2 changes: 1 addition & 1 deletion backend-contract/fixtures/negative/wrong-length-color.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "6600000000000000000000e1",
"intent": "add-segment-group",
"intent": "import-segmentation",
"url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz",
"name": "otsu.nii.gz",
"segments": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "6600000000000000000000e2",
"intent": "add-segment-group",
"intent": "import-segmentation",
"url": "/api/v1/file/6600000000000000000000e2/proxiable/threshold.seg.nrrd",
"name": "threshold.seg.nrrd",
"source": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "6600000000000000000000e1",
"intent": "add-segment-group",
"intent": "import-segmentation",
"url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz",
"name": "otsu.nii.gz",
"segments": [
Expand Down
2 changes: 1 addition & 1 deletion backend-contract/generated/job-results.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
"properties": {
"intent": {
"type": "string",
"const": "add-segment-group"
"const": "import-segmentation"
},
"id": {
"type": "string",
Expand Down
6 changes: 3 additions & 3 deletions backend-contract/generated/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "VolView neutral backend contract",
"version": "0.2.0",
"description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 2 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)."
"version": "0.3.0",
"description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 3 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)."
},
"servers": [
{
Expand Down Expand Up @@ -1203,7 +1203,7 @@
"properties": {
"intent": {
"type": "string",
"const": "add-segment-group"
"const": "import-segmentation"
},
"id": {
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion backend-contract/generated/result-intent.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"properties": {
"intent": {
"type": "string",
"const": "add-segment-group"
"const": "import-segmentation"
},
"id": {
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion backend-contract/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@volview/backend-contract",
"version": "0.2.0",
"version": "0.3.0",
"private": true
}
26 changes: 13 additions & 13 deletions backend-contract/processing/__tests__/wire.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,12 @@ describe('neutral job status fixtures', () => {
// ---------------------------------------------------------------------------

describe('result intent fixtures', () => {
it('exports vocabulary version 2 and the exactly-four state intents', () => {
expect(INTENT_VOCABULARY_VERSION).toBe(2);
it('exports vocabulary version 3 and the exactly-four state intents', () => {
expect(INTENT_VOCABULARY_VERSION).toBe(3);
expect([...RESULT_INTENTS]).toEqual([
'add-base-image',
'add-layer',
'add-segment-group',
'import-segmentation',
'add-annotations',
]);
expect(wire).not.toHaveProperty('intent.download');
Expand All @@ -208,8 +208,8 @@ describe('result intent fixtures', () => {
it.each([
'intent.add-base-image',
'intent.add-layer',
'intent.add-segment-group.with-segments',
'intent.add-segment-group.embedded',
'intent.import-segmentation.with-segments',
'intent.import-segmentation.embedded',
'intent.add-annotations',
'intent.unknown',
])('validates %s', (name) => {
Expand Down Expand Up @@ -253,11 +253,11 @@ describe('result intent fixtures', () => {
).toBe(false);
});

it('parses add-segment-group WITH segments and a source provenance tag', () => {
it('parses import-segmentation WITH segments and a source provenance tag', () => {
const parsed = resultIntentSchema.parse(
wire['intent.add-segment-group.with-segments']
wire['intent.import-segmentation.with-segments']
) as Record<string, unknown>;
expect(parsed.intent).toBe('add-segment-group');
expect(parsed.intent).toBe('import-segmentation');
expect(Array.isArray(parsed.segments)).toBe(true);
expect(parsed.source).toEqual({
providerId: 'analysis-provider',
Expand All @@ -266,18 +266,18 @@ describe('result intent fixtures', () => {
});
});

it('parses add-segment-group WITHOUT segments (embedded metadata) but with source', () => {
it('parses import-segmentation WITHOUT segments (embedded metadata) but with source', () => {
const parsed = resultIntentSchema.parse(
wire['intent.add-segment-group.embedded']
wire['intent.import-segmentation.embedded']
) as Record<string, unknown>;
expect(parsed.intent).toBe('add-segment-group');
expect(parsed.intent).toBe('import-segmentation');
expect(parsed.segments).toBeUndefined();
expect(parsed.source).toMatchObject({ outputId: 'outputLabelmap' });
});

it('rejects a segment-group source without provider identity', () => {
const value = structuredClone(
wire['intent.add-segment-group.with-segments']
wire['intent.import-segmentation.with-segments']
) as { source: { providerId?: string } };
delete value.source.providerId;
expect(knownResultIntentSchema.safeParse(value).success).toBe(false);
Expand Down Expand Up @@ -373,7 +373,7 @@ describe('result intent fixtures', () => {
expect(knownResultIntentSchema.safeParse(short).success).toBe(false);
expect(resultIntentSchema.safeParse(short).success).toBe(true);

const good = wire['intent.add-segment-group.with-segments'] as {
const good = wire['intent.import-segmentation.with-segments'] as {
segments: { color: number[] }[];
};
const long = structuredClone(good);
Expand Down
2 changes: 1 addition & 1 deletion backend-contract/processing/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ export const buildOpenApiDocument = (): Record<string, unknown> => ({
// VERSION / specVersion) below. It is deliberately literal, not derived from
// the shape-version constants — the artifact and the shapes version on
// separate clocks.
version: '0.2.0',
version: '0.3.0',
description:
'DRAFT 0.x — shapes may change until a second backend passes the ' +
'conformance kit (the pinned 1.0 criterion). ' +
Expand Down
30 changes: 23 additions & 7 deletions backend-contract/processing/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ import {
} from './task-spec';
import { pathSegmentIdSchema } from './ids';

// Bump when the intent vocabulary's shape changes so producers and the applier
// can negotiate compatibility. Adding an intent is a compatible bump: an older
// client demotes the unknown intent through the fail-open branch above.
export const INTENT_VOCABULARY_VERSION = 2;
// A client-side marker for the shape of the intent vocabulary below, bumped
// when that shape changes. It never travels on the wire: no request header,
// response field, or schema property carries it, so the two sides never see
// each other's value and cannot negotiate on it. It names the vocabulary in
// the generated OpenAPI description and in this package's release notes.
// Adding an intent stays compatible without it: an older client demotes the
// unknown intent through the fail-open branch above.
export const INTENT_VOCABULARY_VERSION = 3;

// ---------------------------------------------------------------------------
// Input value: what the client sends at submit
Expand Down Expand Up @@ -158,11 +162,23 @@ export type NeutralJobStatus = z.infer<typeof neutralJobStatusSchema>;
export const RESULT_INTENTS = [
'add-base-image',
'add-layer',
'add-segment-group',
'import-segmentation',
'add-annotations',
] as const;
export type ResultIntentName = (typeof RESULT_INTENTS)[number];

// Names an earlier vocabulary gave an intent whose shape has not changed since.
// A client reads one as its current name, so a producer still on the old
// vocabulary keeps applying and the two sides need not deploy in lockstep.
export const LEGACY_RESULT_INTENT_NAMES: Readonly<
Record<string, ResultIntentName>
> = {
'add-segment-group': 'import-segmentation',
};

export const currentResultIntentName = (intent: unknown) =>
(typeof intent === 'string' && LEGACY_RESULT_INTENT_NAMES[intent]) || intent;

// Provenance tag on a result: the durable idempotency identity the client
// preserves on generated scene state so restored results can be recognized.
export const resultSourceSchema = z.object({
Expand Down Expand Up @@ -212,13 +228,13 @@ const addLayer = z
.object({ intent: z.literal('add-layer'), ...resultListItemSchema.shape })
.passthrough();

// `add-segment-group` carries OPTIONAL `segments` (the bare-labelmap +
// `import-segmentation` carries OPTIONAL `segments` (the bare-labelmap +
// labels-sidecar case; a `seg.nrrd` with embedded metadata carries none — the
// client uses `segments` when present, else the file's own metadata) and an
// optional `source` provenance tag (the idempotency key).
const addSegmentGroup = z
.object({
intent: z.literal('add-segment-group'),
intent: z.literal('import-segmentation'),
...resultListItemSchema.shape,
segments: z.array(segmentDescriptorSchema).optional(),
source: resultSourceSchema.optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/components/EditableItemList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const moveBy = async (key: ItemKey, offset: number, event: KeyboardEvent) => {
class="reorder-handle"
draggable="true"
:aria-label="`Reorder ${title}`"
title="Drag to reorder segments and shortcuts. Earlier segments render in front. Alt+Up or Alt+Down also moves this segment."
title="Drag to reorder segments and shortcuts. Earlier segments win picking and flattened export; overlaps blend in the view. Alt+Up or Alt+Down also moves this segment."
@click.stop
@dragstart.stop="startDrag($event, key)"
@dragend="clearDrag"
Expand Down
2 changes: 1 addition & 1 deletion src/components/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default defineComponent({
const dark = ref(theme.global.name.value === DarkTheme);

watch(dark, (isDark) => {
theme.global.name.value = isDark ? DarkTheme : LightTheme;
theme.change(isDark ? DarkTheme : LightTheme);
store.value = theme.global.name.value;
});

Expand Down
20 changes: 20 additions & 0 deletions src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
boundMasks,
} from '@/src/segmentation/__tests__/segmentMaskFixtures';
import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue';
import { compositeLabelmap } from '@/src/segmentation/io/composition';
import { labelmapScalars } from '@/src/segmentation/io/labelmap';

// ---------------------------------------------------------------------------
// The state file carries N bounded masks. What goes into the archive is each
Expand Down Expand Up @@ -482,6 +484,16 @@ describe('bounded masks through the state file', () => {
extent: [3, 4, 1, 1, 1, 1],
reason: 'extent leaves the parent image',
},
{
title: 'an extent with fractional coordinates',
extent: [0.5, 1.5, 1, 1, 1, 1],
reason: 'extent coordinates must be finite integers',
},
{
title: 'a fractional extent whose axes otherwise look empty',
extent: [0.5, -0.5, 0, -1, 0, -1],
reason: 'extent coordinates must be finite integers',
},
])('rejects $title', async ({ extent, reason }) => {
await buildScene();

Expand All @@ -494,6 +506,14 @@ describe('bounded masks through the state file', () => {

expect(restoredSegment('Tumor').representations.labelmap).toBeUndefined();
expect(result.skipped).toContainEqual({ name: storage!.name, reason });
expect(markedVoxels(restoredSegment('Node').id)).toEqual([
[3, 3, 3, SEGMENT_VALUE],
]);
expect(
Array.from(labelmapScalars(compositeLabelmap('new-1').labelmap)).flatMap(
(value, index) => (value === 0 ? [] : [[index, value]])
)
).toEqual([[63, 2]]);
expect(boundMasks()).toHaveLength(3);
});

Expand Down
2 changes: 1 addition & 1 deletion src/plugins/vuetify.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ const theme = useLocalStorage(ThemeStorageKey, DefaultTheme);
if (theme.value !== DarkTheme && theme.value !== LightTheme) {
theme.value = DefaultTheme;
}
vuetify.theme.global.name.value = theme.value;
vuetify.theme.change(theme.value);

export default vuetify;
18 changes: 11 additions & 7 deletions src/processing/__tests__/applyResults.annotations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,13 +729,17 @@ describe('applyIntent — add-annotations', () => {
expect(toolCounts()).toEqual({ rulers: 0, rectangles: 0, polygons: 0 });
});

it('applies without a source when the producer omitted one', async () => {
const outcome = await apply(
intent({ source: undefined }),
context(IMAGE_ID)
);
expect(outcome.status).toBe('applied');
expect(onlyTool(useRulerStore()).source).toBeUndefined();
it('mints the receipt from the submitted job when the producer omitted a source', async () => {
const unsourced = intent({ source: undefined });
expect((await apply(unsourced, context(IMAGE_ID))).status).toBe('applied');
expect(onlyTool(useRulerStore()).source).toEqual({
providerId: 'provider-1',
jobId: 'job-1',
outputId: 'r1',
});

await apply(unsourced, context(IMAGE_ID));
expect(toolCounts()).toEqual({ rulers: 1, rectangles: 1, polygons: 1 });
});

// A stored `frame` flips a tool into cine semantics (render slice,
Expand Down
28 changes: 21 additions & 7 deletions src/processing/__tests__/applyResults.segments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
appApplyDependencies,
} from '@/src/processing/applyResults';
import { buildSegNrrdMetadata } from '@/src/io/segNrrdMetadata';
import { isEmptyExtent } from '@/src/segmentation/geometry';
import { useImageCacheStore } from '@/src/store/image-cache';
import { useSegmentationStore } from '@/src/segmentation/store';
import { useSegmentStore } from '@/src/segmentation/segments';
Expand All @@ -33,7 +34,7 @@ const existingMask = (imageId: string, name: string) => {
const importResult = (segments?: SegmentDescriptor[]) =>
applyIntent(
{
intent: 'add-segment-group',
intent: 'import-segmentation',
id: 'result',
name: 'output.nrrd',
url: 'https://example/output.nrrd',
Expand Down Expand Up @@ -117,7 +118,7 @@ describe('processing segment identity', () => {
]);
});

it('overrides embedded names before binding and preserves undescribed source values', async () => {
it('overrides embedded names, keeps undescribed source values, and keeps a declared empty', async () => {
existingMask('parent-A', 'Embedded');
const output = useImageCacheStore().imageById.output;
output.headerMetadata = buildSegNrrdMetadata(
Expand All @@ -128,20 +129,33 @@ describe('processing segment identity', () => {
scalars.getData()[42] = 7;
scalars.modified();

await importResult([
{ value: 1, name: 'Explicit', color: blue, visible: false },
{ value: 99, name: 'Absent', color: blue },
]);
// Asserted, not discarded: the import pairs the masks it minted with the
// descriptors it decoded by position, so a descriptor list that grows
// after the decode fails the whole apply rather than the row below.
expect(
await importResult([
{ value: 1, name: 'Explicit', color: blue, visible: false },
{ value: 99, name: 'Absent', color: blue },
])
).toEqual({ status: 'applied' });

// Value 99 has no voxels and no header block. A segment a result DECLARES
// but leaves EMPTY appears as an empty row, so it is minted after the
// decoded ones.
expect(appearanceOnB()).toMatchObject([
{ name: 'Explicit', color: blue, visible: false },
{ name: 'output 7' },
{ name: 'Absent', color: blue, visible: true },
]);
expect(registry().findSegmentByName('Embedded')?.color).toEqual(red);
expect(registry().findSegmentByName('Absent')).toBeUndefined();
expect(
store().imageMasks('parent-B')[1].representations.labelmap?.extent
).toEqual([2, 2, 2, 2, 2, 2]);
expect(
isEmptyExtent(
store().imageMasks('parent-B')[2].representations.labelmap!.extent
)
).toBe(true);
});

it('applies source-value descriptions independently to every component', async () => {
Expand Down
Loading
Loading