Skip to content
Merged
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
29 changes: 7 additions & 22 deletions PanTS-Demo/src/helpers/CornerstoneNifti2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import vtkImageMarchingCubes from "@kitware/vtk.js/Filters/General/ImageMarching
import type { MaskingArea } from "../components/segmentation/MaskingSelect";
import { createOperationGeneration } from "./viewer/operationGeneration";
import { rollbackVolumeUpgrade } from "./viewer/volumeUpgrade";
import { addVolumeLabelmap } from "./viewer/addVolumeLabelmap";
type viewportIdTypes = 'CT_NIFTI_AXIAL' | 'CT_NIFTI_SAGITTAL' | 'CT_NIFTI_CORONAL';

const {
Expand Down Expand Up @@ -740,20 +741,10 @@ export async function renderVisualization(ref1: HTMLDivElement, ref2: HTMLDivEle
},
]);

// Wait until every viewport owns its representation before reporting that the
// viewer is ready. Challenge mode hides the ground-truth segments as soon as
// loading ends; letting these promises float races that visibility update and
// can leave the default labelmap painted over the CT.
// Attach each actor before registering its representation. Registration alone
// does not await actor creation and can stack duplicate masks during loading.
await Promise.all(viewportInputArray.map(async (viewport) => {
await segmentation.addSegmentationRepresentations(viewport.viewportId, [
{
segmentationId: segmentationVolumeId,
type: csToolsEnums.SegmentationRepresentations.Labelmap,
config: {
colorLUTOrIndex: convertedColorLUT
}
}
]);
await addVolumeLabelmap(renderingEngine, viewport.viewportId, segmentationVolumeId, convertedColorLUT);
segmentation.activeSegmentation.setActiveSegmentation(viewport.viewportId, segmentationVolumeId);
}));
_throwIfViewerLoadStale(context, opts?.signal);
Expand Down Expand Up @@ -1929,7 +1920,7 @@ export async function captureViewportImages(): Promise<ViewportImage[]> {

/* eslint-disable @typescript-eslint/no-explicit-any -- optional cornerstone APIs probed at runtime */
async function _rebuildSegmentationRepresentations() {
if (!_lastColorLUT || !cache.getVolume(segmentationId)) return;
if (!_lastColorLUT || !cache.getVolume(segmentationId) || !currentRenderingEngine) return;
for (const viewportId of MPR_VIEWPORT_IDS) {
try {
// Drop the (now actor-less) representation entry first so re-adding isn't a no-op.
Expand All @@ -1940,13 +1931,7 @@ async function _rebuildSegmentationRepresentations() {
} catch {
/* nothing to remove */
}
await segmentation.addSegmentationRepresentations(viewportId, [
{
segmentationId,
type: csToolsEnums.SegmentationRepresentations.Labelmap,
config: { colorLUTOrIndex: _lastColorLUT },
},
]);
await addVolumeLabelmap(currentRenderingEngine, viewportId, segmentationId, _lastColorLUT);
segmentation.activeSegmentation.setActiveSegmentation(viewportId, segmentationId);
}
}
Expand Down Expand Up @@ -6200,4 +6185,4 @@ export function endBrushMaskGuard(area: MaskingArea, ids: number[]) {
}
}
if (reverted) _notifySegmentationChanged();
}
}
5 changes: 2 additions & 3 deletions PanTS-Demo/src/helpers/compareViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import * as tools from "@cornerstonejs/tools";
import { SegmentationRepresentations } from "@cornerstonejs/tools/enums";
import type { Color, ColorLUT } from "@cornerstonejs/core/types";
import { addVolumeLabelmap } from "./viewer/addVolumeLabelmap";
import { segmentation_category_colors } from "./constants";

const ENGINE_ID = "cmp_engine";
Expand Down Expand Up @@ -575,9 +576,7 @@ async function loadCase(
},
]);
for (const vpId of viewportIds) {
await tools.segmentation.addSegmentationRepresentations(vpId, [
{ segmentationId, type: SegmentationRepresentations.Labelmap, config: { colorLUTOrIndex: colorLUT } },
]);
await addVolumeLabelmap(engine, vpId, segmentationId, colorLUT);
tools.segmentation.activeSegmentation.setActiveSegmentation(vpId, segmentationId);
}
} catch (e) {
Expand Down
138 changes: 138 additions & 0 deletions PanTS-Demo/src/helpers/viewer/addVolumeLabelmap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VolumeViewport, type RenderingEngine, type Types } from "@cornerstonejs/core";
import vtkPiecewiseFunction from "@kitware/vtk.js/Common/DataModel/PiecewiseFunction";
import vtkColorTransferFunction from "@kitware/vtk.js/Rendering/Core/ColorTransferFunction";
import { render } from "@cornerstonejs/tools/tools/displayTools/Labelmap/labelmapDisplay";
import { segmentationStyle } from "@cornerstonejs/tools/segmentation/SegmentationStyle";
import { addVolumeLabelmap } from "./addVolumeLabelmap";

const state = vi.hoisted(() => ({
viewport: null as unknown,
engine: null as unknown,
register: vi.fn(),
}));
vi.mock("@cornerstonejs/core", async (importOriginal) => ({
...await importOriginal<typeof import("@cornerstonejs/core")>(),
getEnabledElement: () => ({ viewport: state.viewport, renderingEngine: state.engine }),
getEnabledElementByViewportId: () => ({ viewport: state.viewport, renderingEngine: state.engine }),
}));
vi.mock("@cornerstonejs/tools", () => ({
Enums: { SegmentationRepresentations: { Labelmap: "Labelmap" } },
segmentation: { addSegmentationRepresentations: state.register },
}));
vi.mock("@cornerstonejs/tools/segmentation/getSegmentation", () => ({
getSegmentation: () => ({ representationData: { Labelmap: { volumeId: "mask", imageIds: ["mask:0"] } } }),
}));
vi.mock("@cornerstonejs/tools/segmentation/activeSegmentation", () => ({
getActiveSegmentation: () => ({ segmentationId: "mask" }),
}));
vi.mock("@cornerstonejs/tools/segmentation/getColorLUT", () => ({
getColorLUT: () => [[0, 0, 0, 0], [255, 0, 0, 255]],
}));
vi.mock("@cornerstonejs/tools/segmentation/helpers/internalGetHiddenSegmentIndices", () => ({
internalGetHiddenSegmentIndices: () => new Set(),
}));

describe("volume labelmap loading", () => {
const colorLUT: Types.ColorLUT = [[0, 0, 0, 0], [255, 0, 0, 255]];
let actors: Types.ActorEntry[];
let releaseLoad: () => void;
let pendingRenders: Promise<unknown>[];
let viewport: VolumeViewport;
let engine: RenderingEngine;
let ofun: ReturnType<typeof vtkPiecewiseFunction.newInstance>;

beforeEach(async () => {
state.register.mockReset();
const { cache } = await import("@cornerstonejs/core");
vi.spyOn(cache, "getVolume").mockReturnValue({} as never);
actors = [];
pendingRenders = [];
const loading = new Promise<void>((resolve) => { releaseLoad = resolve; });
// Exercise the real Cornerstone renderer and actor lookup. Only the GPU actor
// construction is replaced, with a deferred NIfTI load spanning several frames.
viewport = Object.create(VolumeViewport.prototype) as VolumeViewport;
Object.assign(viewport, {
id: "loading-test", element: document.createElement("div"),
getActors: () => actors,
addVolumes: vi.fn(async (inputs: Types.IVolumeInput[]) => {
await loading;
for (const input of inputs) {
const uid = input.actorUID ?? `actor-${actors.length}`;
if (actors.some((entry) => entry.uid === uid)) continue;
actors.push({
uid, representationUID: input.representationUID,
actor: {
getProperty: () => ({
setRGBTransferFunction: vi.fn(), setScalarOpacity: vi.fn(),
setInterpolationTypeToNearest: vi.fn(), setLabelOutlineThickness: vi.fn(),
}),
setVisibility: vi.fn(), modified: vi.fn(),
},
} as unknown as Types.ActorEntry);
}
}),
});
engine = { getViewport: () => viewport } as unknown as RenderingEngine;
state.viewport = viewport;
state.engine = engine;
ofun = vtkPiecewiseFunction.newInstance();
segmentationStyle.setStyle({ type: "Labelmap" as never, segmentationId: "mask" }, {
fillAlpha: 0.5, renderFill: true, renderOutline: false,
});
state.register.mockImplementation(() => {
// Representation-added, active-segmentation and loading events can each
// schedule a render before asynchronous actor creation has completed.
for (let frame = 0; frame < 4; frame++) {
pendingRenders.push(render(viewport, {
segmentationId: "mask", colorLUTIndex: 0,
config: { cfun: vtkColorTransferFunction.newInstance(), ofun },
} as Parameters<typeof render>[1]));
}
});
});

afterEach(() => vi.restoreAllMocks());

it("adds a single colored layer even when renders overlap a slow NIfTI load", async () => {
const ready = addVolumeLabelmap(engine, viewport.id, "mask", colorLUT);
releaseLoad();
await ready;
await Promise.all(pendingRenders);
expect(actors).toHaveLength(1);
// Repeated source-over layers turn 50% into 94% with four copies. One
// representation must retain the selected opacity instead of saturating.
expect(1 - (1 - ofun.getValue(1)) ** actors.length).toBeCloseTo(0.5);
});

it("keeps one layer on repeat registration and after an HD actor rebuild", async () => {
releaseLoad();
await addVolumeLabelmap(engine, viewport.id, "mask", colorLUT);
await Promise.all(pendingRenders);
await addVolumeLabelmap(engine, viewport.id, "mask", colorLUT);
await Promise.all(pendingRenders);
expect(actors).toHaveLength(1);
expect(viewport.addVolumes).toHaveBeenCalledTimes(1);

actors = []; // setVolumes replaces actors when switching to HD CT.
await addVolumeLabelmap(engine, viewport.id, "mask", colorLUT);
await Promise.all(pendingRenders);
expect(actors).toHaveLength(1);
expect(viewport.addVolumes).toHaveBeenCalledTimes(2);
});

it("does not publish a representation before its actor is ready or after load failure", async () => {
const ready = addVolumeLabelmap(engine, viewport.id, "mask", colorLUT);
expect(state.register).not.toHaveBeenCalled();
releaseLoad();
await ready;
await Promise.all(pendingRenders);
expect(state.register).toHaveBeenCalledTimes(1);

actors = [];
state.register.mockClear();
vi.mocked(viewport.addVolumes).mockRejectedValueOnce(new Error("NIfTI load failed"));
await expect(addVolumeLabelmap(engine, viewport.id, "mask", colorLUT)).rejects.toThrow("NIfTI load failed");
expect(state.register).not.toHaveBeenCalled();
});
});
34 changes: 34 additions & 0 deletions PanTS-Demo/src/helpers/viewer/addVolumeLabelmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Enums as CoreEnums, type RenderingEngine, type Types } from "@cornerstonejs/core";
import { Enums, segmentation } from "@cornerstonejs/tools";

/** Attach the volume actor before publishing its labelmap representation.
*
* Cornerstone 4's representation registration is synchronous; its renderer adds
* the actor asynchronously. Several render requests during a slow NIfTI load can
* each see no actor and add another one. Those layers compound the fill alpha
* (four 50% layers appear 94% opaque). Pre-attaching one actor closes that window.
* The segmentation ID is also its cached volume ID in both BodyMaps viewers.
*/
export async function addVolumeLabelmap(
engine: RenderingEngine,
viewportId: string,
segmentationId: string,
colorLUT: Types.ColorLUT,
): Promise<void> {
const viewport = engine.getViewport(viewportId) as Types.IVolumeViewport;
const representationUID = `${segmentationId}-${Enums.SegmentationRepresentations.Labelmap}`;
if (!viewport.getActors().some((entry) => entry.representationUID === representationUID)) {
await viewport.addVolumes([{
volumeId: segmentationId,
actorUID: representationUID,
representationUID,
visibility: true,
blendMode: CoreEnums.BlendModes.MAXIMUM_INTENSITY_BLEND,
}], false, true);
}
segmentation.addSegmentationRepresentations(viewportId, [{
segmentationId,
type: Enums.SegmentationRepresentations.Labelmap,
config: { colorLUTOrIndex: colorLUT },
}]);
}
10 changes: 10 additions & 0 deletions PanTS-Demo/src/helpers/viewer/segmentationOpacity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ describe("segmentation opacity in the CT renderer", () => {
expect(property.setLabelOutlineOpacity).toHaveBeenLastCalledWith(0.8);
});

it("preserves distinct fill levels throughout 50–100%, including changes back down", async () => {
for (const alpha of [0.5, 0.6, 0.75, 0.9, 1, 0.75, 0.5, 0]) {
setFillOpacity(alpha);
await draw();
expect(ofun.getValue(1)).toBeCloseTo(alpha);
expect(ofun.getValue(2)).toBeCloseTo(alpha * 128 / 255);
expect(ofun.getValue(0)).toBe(0);
}
});

it("also renders inactive labels with exactly zero fill", async () => {
activeSegmentation.segmentationId = "another-segmentation";
segmentationStyle.setRenderInactiveSegmentations(viewport.id, true);
Expand Down
Loading