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"} +