From cf469330e5021afd40d25c0d7268999638b93836 Mon Sep 17 00:00:00 2001 From: Aditya Sanjeev Date: Thu, 12 Nov 2026 20:52:14 -0800 Subject: [PATCH] MRI viewer: PACS-style window presets and an Invert toggle MRI scans get windows from the scan's own intensity distribution, since MRI has no fixed units. The preset list is now Auto, Low contrast, Medium contrast, High contrast and Full range. Low, Medium and High follow the usual PACS automatic presets and drop 1%, 5% and 10% of the foreground voxels from each end of the range (narrower window, more contrast). The old "High contrast" window, which only raised the lower bound, is replaced. ScanAnalysis gains the extra foreground percentiles these need. The View menu gets an Invert toggle (state shown in place, like Hover identify and Reference lines). It flips the grayscale of every pane through Cornerstone's invert property and is re-applied when the viewports are rebuilt. It is not saved between visits. Tests: preset names, trim edges and ordering in scanModality.test.ts; a viewer smoke test for the Invert menu item. Ablations: changing the High contrast trim fails the preset tests; making the Invert click a no-op fails the smoke test. Checked in a real browser with a synthetic MRI NIfTI: the scan is detected as MRI, the five presets are listed, and Invert flips all three panes. --- PanTS-Demo/src/helpers/scanModality.ts | 34 ++++++++++++++----- PanTS-Demo/src/routes/VisualizationPage.tsx | 37 ++++++++++++++++++++- PanTS-Demo/src/test/scanModality.test.ts | 32 ++++++++++++++---- PanTS-Demo/src/test/viewer.smoke.test.tsx | 14 ++++++++ 4 files changed, 101 insertions(+), 16 deletions(-) diff --git a/PanTS-Demo/src/helpers/scanModality.ts b/PanTS-Demo/src/helpers/scanModality.ts index ffb4e7ff..4939bae1 100644 --- a/PanTS-Demo/src/helpers/scanModality.ts +++ b/PanTS-Demo/src/helpers/scanModality.ts @@ -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; @@ -57,7 +64,7 @@ export function analyzeScan(data: ArrayLike, 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; @@ -97,6 +104,12 @@ export function analyzeScan(data: ArrayLike, 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, }; @@ -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): 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): 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) }, ]; } diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index ee903e95..67d8360f 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -14,6 +14,7 @@ import { IconChartBar, IconCheck, IconChevronDown, + IconContrast2, IconCircle, IconClick, IconEye, @@ -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, @@ -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. @@ -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 @@ -4732,6 +4754,19 @@ const aiAvailableOrgans = useMemo(() => { {hoverIdentifyEnabled ? "Hover identify: on" : "Hover identify"} +