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
34 changes: 25 additions & 9 deletions PanTS-Demo/src/helpers/scanModality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type ScanAnalysis = {
p2: number;
p98: number;
p995: number;
/** Further foreground percentiles, for the PACS-style Low / Medium / High contrast windows. */
p1: number;
p5: number;
p10: number;
p90: number;
p95: number;
p99: number;
/** Share of sampled voxels that sit in the CT air band (-1100 to -900 HU). */
airFraction: number;
reason: string;
Expand Down Expand Up @@ -57,7 +64,7 @@ export function analyzeScan(data: ArrayLike<number>, name?: string): ScanAnalysi
}
const values = sample.subarray(0, n);
values.sort();
if (!n) return { modality: "CT", min: 0, max: 0, p2: 0, p98: 0, p995: 0, airFraction: 0, reason: "no finite voxels" };
if (!n) return { modality: "CT", min: 0, max: 0, p2: 0, p98: 0, p995: 0, p1: 0, p5: 0, p10: 0, p90: 0, p95: 0, p99: 0, airFraction: 0, reason: "no finite voxels" };
const min = values[0];
const max = values[n - 1];
let air = 0;
Expand Down Expand Up @@ -97,6 +104,12 @@ export function analyzeScan(data: ArrayLike<number>, name?: string): ScanAnalysi
p2: percentile(foreground, 0.02),
p98: percentile(foreground, 0.98),
p995: percentile(foreground, 0.995),
p1: percentile(foreground, 0.01),
p5: percentile(foreground, 0.05),
p10: percentile(foreground, 0.1),
p90: percentile(foreground, 0.9),
p95: percentile(foreground, 0.95),
p99: percentile(foreground, 0.99),
airFraction,
reason,
};
Expand All @@ -107,15 +120,18 @@ function toWindow(low: number, high: number): { width: number; center: number }
return { width, center: low + width / 2 };
}

/** MRI window presets, all derived from the scan's own intensity distribution. The first one is the default. */
export function mrWindowPresets(a: Pick<ScanAnalysis, "min" | "max" | "p2" | "p98" | "p995">): MrWindowPreset[] {
const auto = toWindow(a.p2, a.p995);
const bright = toWindow(a.p2 + (a.p98 - a.p2) * 0.35, a.p995);
const wide = toWindow(a.min, a.max);
/**
* MRI window presets, all derived from the scan's own intensity distribution. The first one is the default.
* Low, Medium and High contrast follow the usual PACS automatic presets: they drop 1%, 5% and 10% of the
* foreground voxels from each end of the range, so a larger trim gives a narrower window and more contrast.
*/
export function mrWindowPresets(a: Pick<ScanAnalysis, "min" | "max" | "p2" | "p995" | "p1" | "p5" | "p10" | "p90" | "p95" | "p99">): MrWindowPreset[] {
return [
{ name: "Auto", ...auto },
{ name: "High contrast", ...bright },
{ name: "Full range", ...wide },
{ name: "Auto", ...toWindow(a.p2, a.p995) },
{ name: "Low contrast", ...toWindow(a.p1, a.p99) },
{ name: "Medium contrast", ...toWindow(a.p5, a.p95) },
{ name: "High contrast", ...toWindow(a.p10, a.p90) },
{ name: "Full range", ...toWindow(a.min, a.max) },
];
}

Expand Down
37 changes: 36 additions & 1 deletion PanTS-Demo/src/routes/VisualizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IconChartBar,
IconCheck,
IconChevronDown,
IconContrast2,
IconCircle,
IconClick,
IconEye,
Expand Down Expand Up @@ -1916,6 +1917,8 @@ function VisualizationPage({ liveRoom, soloChallenge, quizPractice }: Visualizat
});

const [hoverIdentifyEnabled, setHoverIdentifyEnabled] = useState(savedPrefs.hoverIdentify ?? false);
// Black-on-white display of the CT/MRI image (the PACS "invert" toggle). Not saved: it resets with the page.
const [invertOn, setInvertOn] = useState(false);
// --- Persist the readout preferences ----------------------------------
// Debounced, because window/level and cine fps change on every drag frame and
// localStorage writes are synchronous. Skipped in a live room / challenge,
Expand Down Expand Up @@ -3353,6 +3356,25 @@ function VisualizationPage({ liveRoom, soloChallenge, quizPractice }: Visualizat
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderingEngine, viewportIds, volumeId]);

// Invert the grayscale of every pane. Cornerstone flips the colours of the volume's transfer function and keeps
// them when the window is re-applied above. Re-run when the viewports are rebuilt so a new load keeps the state.
useEffect(() => {
if (!renderingEngine || !viewportIds.length || !volumeId) return;
viewportIds.forEach((viewportId) => {
const viewport = renderingEngine.getViewport(viewportId) as unknown as {
setProperties?: (props: { invert: boolean }, volumeId?: string) => void;
render: () => void;
} | undefined;
if (!viewport?.setProperties) return;
try {
viewport.setProperties({ invert: invertOn }, volumeId);
viewport.render();
} catch (err) {
console.warn(`Invert failed for ${viewportId}:`, err);
}
});
}, [renderingEngine, viewportIds, volumeId, invertOn]);

// Track each pane's current/total slice for the "245/519" caption + drag scrollbar.
// Re-subscribes on every volume (re)load, since a fresh render tears down the old
// viewport elements the previous subscription's listeners were attached to.
Expand Down Expand Up @@ -3455,7 +3477,7 @@ function VisualizationPage({ liveRoom, soloChallenge, quizPractice }: Visualizat

// Group-level "something inside is active" flags, so each collapsed toolbar dropdown
// still visually reflects its contents' state without having to be open.
const viewGroupActive = hoverIdentifyEnabled || referenceLinesOn;
const viewGroupActive = hoverIdentifyEnabled || referenceLinesOn || invertOn;
const panelsGroupActive = showOrganDetails || showStats || showMetadata || showMeasurePanel;
const collaborationDisabled = !viewerReady || Boolean(liveRoom && (
liveRoom.connectionState !== "connected" || liveRoom.collaborationLocked
Expand Down Expand Up @@ -4732,6 +4754,19 @@ const aiAvailableOrgans = useMemo(() => {
<IconScanEye size={18} />
<span>{hoverIdentifyEnabled ? "Hover identify: on" : "Hover identify"}</span>
</button>
<button
className={`vp-flyout__item ${invertOn ? "is-active" : ""}`}
role="menuitem"
title="Show the scan as a negative (bright becomes dark)"
onClick={() => {
setInvertOn((v) => !v);
sessionRef.current?.log("view", invertOn ? "Turned invert off" : "Turned invert on");
viewFlyout.close();
}}
>
<IconContrast2 size={18} />
<span>{invertOn ? "Invert: on" : "Invert"}</span>
</button>
<button
className={`vp-flyout__item ${referenceLinesOn ? "is-active" : ""}`}
role="menuitem"
Expand Down
32 changes: 26 additions & 6 deletions PanTS-Demo/src/test/scanModality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,34 @@ describe("analyzeScan", () => {
});

describe("mrWindowPresets", () => {
const byName = (a: ReturnType<typeof analyzeScan>) => Object.fromEntries(mrWindowPresets(a).map((p) => [p.name, p]));
const edges = (p: { width: number; center: number }) => [p.center - p.width / 2, p.center + p.width / 2];

it("starts with an Auto window that covers the foreground range", () => {
const a = analyzeScan(mrT1());
const [auto, , full] = mrWindowPresets(a);
expect(auto.name).toBe("Auto");
expect(auto.center - auto.width / 2).toBeCloseTo(a.p2);
expect(auto.center + auto.width / 2).toBeCloseTo(a.p995);
expect(full.name).toBe("Full range");
expect(full.width).toBeCloseTo(a.max - a.min);
const presets = mrWindowPresets(a);
expect(presets[0].name).toBe("Auto");
expect(edges(presets[0])[0]).toBeCloseTo(a.p2);
expect(edges(presets[0])[1]).toBeCloseTo(a.p995);
expect(presets.at(-1)?.name).toBe("Full range");
expect(presets.at(-1)?.width).toBeCloseTo(a.max - a.min);
});

it("offers PACS-style Low, Medium and High contrast that trim 1%, 5% and 10% from each end", () => {
const a = analyzeScan(mrT1());
const p = byName(a);
expect(mrWindowPresets(a).map((x) => x.name)).toEqual(["Auto", "Low contrast", "Medium contrast", "High contrast", "Full range"]);
expect(edges(p["Low contrast"])).toEqual([expect.closeTo(a.p1), expect.closeTo(a.p99)]);
expect(edges(p["Medium contrast"])).toEqual([expect.closeTo(a.p5), expect.closeTo(a.p95)]);
expect(edges(p["High contrast"])).toEqual([expect.closeTo(a.p10), expect.closeTo(a.p90)]);
});

it("gets narrower, so more contrasty, as the trim grows", () => {
const p = byName(analyzeScan(mrT1()));
expect(p["Full range"].width).toBeGreaterThan(p["Low contrast"].width);
expect(p["Low contrast"].width).toBeGreaterThan(p["Medium contrast"].width);
expect(p["Medium contrast"].width).toBeGreaterThan(p["High contrast"].width);
expect(p["High contrast"].width).toBeGreaterThan(0);
});
});

Expand Down
14 changes: 14 additions & 0 deletions PanTS-Demo/src/test/viewer.smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,20 @@ describe("viewer smoke test", () => {
expect(flipPaneHorizontal).not.toHaveBeenCalled();
});

it("lets the View menu invert the image, and shows the state in place", async () => {
render(<AuthProvider><MemoryRouter initialEntries={["/case/35"]}>
<Routes><Route path="/case/:caseId" element={<VisualizationPage />} /></Routes>
</MemoryRouter></AuthProvider>);
await waitFor(() => expect(renderVisualization).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "View options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Invert" }));
fireEvent.click(screen.getByRole("button", { name: "View options" }));
expect(screen.getByRole("menuitem", { name: "Invert: on" })).toHaveClass("is-active");
fireEvent.click(screen.getByRole("menuitem", { name: "Invert: on" }));
fireEvent.click(screen.getByRole("button", { name: "View options" }));
expect(screen.getByRole("menuitem", { name: "Invert" })).not.toHaveClass("is-active");
});

it("opens AI, live rooms, and shortcuts directly from the viewer toolbar", async () => {
render(<AuthProvider><MemoryRouter initialEntries={["/case/35"]}>
<Routes><Route path="/case/:caseId" element={<VisualizationPage />} /></Routes>
Expand Down
Loading