Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1ab9df5
refactor(processing): rename the segmentation result instruction
PaulHax Sep 15, 2026
fe33671
feat(segmentation): choose labelmap bit depth from label count
PaulHax Sep 15, 2026
8d13857
feat(processing): pack labelmap inputs by input multiplicity
PaulHax Sep 15, 2026
9689a88
refactor(segmentation): simplify editing and clarify registry ordering
PaulHax Sep 19, 2026
4a04513
fix(processing): keep segments a result declares but leaves empty
PaulHax Sep 20, 2026
83114ba
fix(segmentation): name restored archive labelmap segments after the …
PaulHax Sep 20, 2026
aec9760
fix(segmentation): keep Enter in the save dialog to one save
PaulHax Sep 20, 2026
5b20076
fix(state): keep the label values a saved labelmap's segments do not …
PaulHax Sep 21, 2026
5ba8483
fix(state): reject fractional saved mask bounds
PaulHax Sep 16, 2026
466a66d
fix(processing): say why a job result was skipped
PaulHax Sep 19, 2026
4cbccb7
fix(processing): read add-segment-group as import-segmentation
PaulHax Sep 21, 2026
27c5d43
fix(processing): recognize re-applied results that carry no wire source
PaulHax Sep 20, 2026
f80a0a3
refactor(processing): route each result intent through its own function
PaulHax Sep 21, 2026
131561a
test(segmentation): strengthen editing and annotation coverage
PaulHax Sep 25, 2026
0d74542
fix(settings): switch theme with theme.change
PaulHax Sep 25, 2026
b01d75f
chore(deps): drop the unused deep-equal dependency
PaulHax Sep 28, 2026
2f4c09e
refactor(segmentation): declare the process workflow props as a type
PaulHax Sep 28, 2026
b43a06c
docs(state): name segments rather than groups in state file comments
PaulHax Sep 28, 2026
d1ac1f6
refactor(utils): drop helpers that no longer have callers
PaulHax Sep 29, 2026
427ff77
fix(processing): offer unroutable intent results as downloads
PaulHax Sep 29, 2026
1b5e4b3
docs(contract): require result ids to be unique within a job
PaulHax Sep 29, 2026
9a488f2
refactor(contract): name the segmentation import schema after its intent
PaulHax Sep 29, 2026
95f92ce
docs(processing): say annotation labels are keyed by segment id
PaulHax Sep 29, 2026
b554244
fix(segmentation): say segment in the shortcut and merge hints
PaulHax Sep 29, 2026
0a66a48
refactor(shortcuts): drop select from the native key claims
PaulHax Sep 29, 2026
c09d62b
refactor(utils): drop the unused standardizeColor helper
PaulHax Sep 29, 2026
8a1b7bd
docs(contract): state the label value range a segment descriptor takes
PaulHax Sep 29, 2026
7865b5c
docs: describe segments in the quick start and shortcut pages
PaulHax Sep 29, 2026
c4ee52c
docs(contract): describe name binding and the staged labelmap file
PaulHax Sep 29, 2026
c849bbe
test: reuse painting setup in segmentation exports
PaulHax Sep 29, 2026
299a4b2
fix(segmentation): disable overlap control when paint is inactive
PaulHax Sep 29, 2026
c16a32d
fix(segmentation): remove deletion success notifications
PaulHax Sep 29, 2026
299c608
feat(paint): match brush cursor to selected segment color
PaulHax Sep 29, 2026
ef6ea74
fix(annotations): remove deletion notifications
PaulHax Sep 29, 2026
a78af7b
style(paint): distribute mode buttons across the panel
PaulHax Sep 29, 2026
eecc68d
fix(state): snapshot annotation identities before mask encoding
PaulHax Sep 29, 2026
cab3a25
fix(segmentation): prepare all import components before creating masks
PaulHax Sep 29, 2026
a11279f
fix(processing): refresh input bindings when retrying cached results
PaulHax Sep 29, 2026
eceda37
docs: keep state file guidance focused on saving and loading
PaulHax Sep 30, 2026
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
25 changes: 22 additions & 3 deletions backend-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,28 @@ Two versions on separate clocks:
- **Artifact version**: `package.json` `version` and the OpenAPI
`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.
- **Shape versions**: `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) names
the shape of the result intent vocabulary in the generated OpenAPI
description and in release notes. It never travels on the wire, so adding an
intent rests on both sides failing open on a name they do not know. The
task-spec `specVersion` does travel: every task spec carries it, and a client
rejects a spec whose version it does not know. Bump it only on a shape
change, never for a new optional field.

### 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
8 changes: 5 additions & 3 deletions 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 Expand Up @@ -155,10 +155,12 @@
"value": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
"maximum": 9007199254740991,
"description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty."
},
"name": {
"type": "string"
"type": "string",
"description": "Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image."
},
"color": {
"minItems": 4,
Expand Down
17 changes: 10 additions & 7 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 @@ -347,7 +347,7 @@
],
"responses": {
"200": {
"description": "The resolved results as the { resultState, intents, missing } envelope (JobResults). Each entry of `intents` is a ResultIntent — a result row carrying a required `id` display key and required `name`/`url`, plus optional/null `mimeType`/`size` file metadata. `missing` counts declared outputs that never arrived plus recorded outputs that cannot be read. Total loss is a valid incomplete response with an empty intents array.",
"description": "The resolved results as the { resultState, intents, missing } envelope (JobResults). Each entry of `intents` is a ResultIntent — a result row carrying a required `id` display key, unique within the job, and required `name`/`url`, plus optional/null `mimeType`/`size` file metadata. `missing` counts declared outputs that never arrived plus recorded outputs that cannot be read. Total loss is a valid incomplete response with an empty intents array.",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -896,7 +896,8 @@
"name",
"referenceImage"
],
"additionalProperties": false
"additionalProperties": false,
"description": "The staged bytes are a `.seg.nrrd` labelmap on the reference image's voxel grid. Label values run from 1 to N within each file, in the client's segment order, with 0 as background. Voxels are unsigned 8-bit for up to 255 labels and unsigned 16-bit beyond. Segment names and colors ride in the header."
},
{
"type": "object",
Expand Down Expand Up @@ -1203,7 +1204,7 @@
"properties": {
"intent": {
"type": "string",
"const": "add-segment-group"
"const": "import-segmentation"
},
"id": {
"type": "string",
Expand Down Expand Up @@ -1244,10 +1245,12 @@
"value": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
"maximum": 9007199254740991,
"description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty."
},
"name": {
"type": "string"
"type": "string",
"description": "Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image."
},
"color": {
"minItems": 4,
Expand Down
8 changes: 5 additions & 3 deletions 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 Expand Up @@ -143,10 +143,12 @@
"value": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
"maximum": 9007199254740991,
"description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty."
},
"name": {
"type": "string"
"type": "string",
"description": "Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image."
},
"color": {
"minItems": 4,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"name",
"referenceImage"
],
"additionalProperties": false
"additionalProperties": false,
"description": "The staged bytes are a `.seg.nrrd` labelmap on the reference image's voxel grid. Label values run from 1 to N within each file, in the client's segment order, with 0 as background. Voxels are unsigned 8-bit for up to 255 labels and unsigned 16-bit beyond. Segment names and colors ride in the header."
},
{
"type": "object",
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
}
52 changes: 33 additions & 19 deletions backend-contract/processing/__tests__/wire.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
jobHistoryDetailSchema,
jobResultsSchema,
jobResultsErrorSchema,
currentResultIntentName,
} from '../wire';
import { loadFixture, loadFixtureDir } from './loadFixtures';

Expand Down Expand Up @@ -193,13 +194,28 @@ describe('neutral job status fixtures', () => {
// Result intents
// ---------------------------------------------------------------------------

describe('currentResultIntentName', () => {
it('reads the earlier segmentation import name as the current one', () => {
expect(currentResultIntentName('add-segment-group')).toBe(
'import-segmentation'
);
});

it.each(['toString', 'constructor', '__proto__', 'add-mesh'])(
'passes %s through unchanged',
(intent) => {
expect(currentResultIntentName(intent)).toBe(intent);
}
);
});

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 +224,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 +269,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 +282,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', () => {
it('rejects a segmentation 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 @@ -364,16 +380,14 @@ describe('result intent fixtures', () => {
});

it('rejects a wrong-length segment color (the tuple-length parity pin)', () => {
// The negative fixture carries a 3-element color. The STRICT union must
// reject it — and the generated JSON Schema must agree (backend side:
// test_contract_fixtures.py), so both validators close fixed-length
// tuples identically. The full union still accepts the row, demoted to an
// ordinary result with no state action (the designed fail-open).
// Only the strict known-intent union rejects the 3-element color; the
// published result-intent schema accepts the row and demotes it to an
// ordinary result with no state action.
const short = loadFixture('negative/wrong-length-color.json');
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
7 changes: 5 additions & 2 deletions backend-contract/processing/annotations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Vector annotations staged into a task or returned as a result. Coordinates
// are world LPS millimeters. Tool records exclude session identity and state;
// labels are namespaced by tool kind because the client stores are independent.
// are world LPS millimeters. Tool records exclude session identity and state.
// Labels are namespaced by tool kind on the wire, but the client binds every
// kind into one segment registry by name: a name repeated across kinds is one
// segment, the first kind to bind a new name (rulers, rectangles, polygons)
// sets its style, and a label no tool references creates nothing.
// Unknown envelope fields survive round-trip without gaining behavior.

import { z } from 'zod';
Expand Down
12 changes: 6 additions & 6 deletions backend-contract/processing/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,11 +429,11 @@ const paths = (): Record<string, unknown> => ({
description:
'The resolved results as the { resultState, intents, missing } envelope ' +
'(JobResults). Each entry of `intents` is a ResultIntent — a result ' +
'row carrying a required `id` display key and required `name`/`url`, ' +
'plus optional/null `mimeType`/`size` file metadata. `missing` counts ' +
'declared outputs that never arrived plus recorded outputs that cannot ' +
'be read. Total loss is a valid incomplete response with an empty ' +
'intents array.',
'row carrying a required `id` display key, unique within the job, and ' +
'required `name`/`url`, plus optional/null `mimeType`/`size` file ' +
'metadata. `missing` counts declared outputs that never arrived plus ' +
'recorded outputs that cannot be read. Total loss is a valid ' +
'incomplete response with an empty intents array.',
content: json(ref('JobResults')),
},
'409': resultReadErrorResponse,
Expand Down 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
Loading
Loading