sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import type { Annotation, AnnotationPoint, AnnotationTool } from './types';
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
view: 'axial' | 'coronal' | 'sagittal';
|
||||
/** Current slice index of this pane — annotations are tagged + filtered by it. */
|
||||
sliceIndex: number;
|
||||
tool: AnnotationTool;
|
||||
annotations: Annotation[];
|
||||
onCommit: (annotation: Annotation) => void;
|
||||
color?: string;
|
||||
/** Double-click (when not finishing a polygon) → expand/restore this pane. */
|
||||
onRequestExpand?: () => void;
|
||||
/** Forward wheel to the underlying viewport so slice-scroll / zoom keep working. */
|
||||
onForwardWheel?: (e: { deltaY: number; ctrlKey: boolean; metaKey: boolean }) => void;
|
||||
}
|
||||
|
||||
const PEN_WIDTH = 2;
|
||||
const BRUSH_WIDTH = 16;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const newId = () =>
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `anno_${Math.round(performance.now())}_${Math.floor(Math.random() * 1e6)}`;
|
||||
|
||||
/**
|
||||
* Per-pane drawing surface for the ImageHub annotation tools. Sits on top of one
|
||||
* 2D viewport (absolute, inset-0). Pointer-transparent while the tool is "none"
|
||||
* (so VTK keeps slice-scroll / zoom / rotate); when a tool is active it captures
|
||||
* events with NATIVE listeners that `stopPropagation()`, so a draw drag never
|
||||
* reaches the VTK interactor bound on the parent canvas (which would otherwise
|
||||
* rotate / window-level the 2D views). Geometry is normalized [0..1] to the pane.
|
||||
*/
|
||||
export function AnnotationOverlay({
|
||||
view,
|
||||
sliceIndex,
|
||||
tool,
|
||||
annotations,
|
||||
onCommit,
|
||||
color = '#22d3ee',
|
||||
onRequestExpand,
|
||||
onForwardWheel,
|
||||
}: AnnotationOverlayProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||
const draftRef = useRef<AnnotationPoint[] | null>(null);
|
||||
const drawingRef = useRef(false);
|
||||
const [, bump] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
// Latest tool + callbacks in refs so the native listeners (added once per
|
||||
// view/slice) always see current values without re-binding mid-drag.
|
||||
const toolRef = useRef(tool);
|
||||
toolRef.current = tool;
|
||||
const cbRef = useRef({ onCommit, onRequestExpand, onForwardWheel, color });
|
||||
cbRef.current = { onCommit, onRequestExpand, onForwardWheel, color };
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setSize({ w: r.width, h: r.height });
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Cancel any in-progress draft when the tool changes or the slice scrolls away.
|
||||
useEffect(() => {
|
||||
draftRef.current = null;
|
||||
drawingRef.current = false;
|
||||
bump();
|
||||
}, [tool, sliceIndex, view]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const toNorm = (e: PointerEvent): AnnotationPoint => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: clamp01((e.clientX - r.left) / r.width), y: clamp01((e.clientY - r.top) / r.height) };
|
||||
};
|
||||
const commit = (t: Exclude<AnnotationTool, 'none'>, points: AnnotationPoint[], strokeWidth?: number) =>
|
||||
cbRef.current.onCommit({ id: newId(), view, sliceIndex, tool: t, points, color: cbRef.current.color, strokeWidth });
|
||||
|
||||
const onDown = (e: PointerEvent) => {
|
||||
const t = toolRef.current;
|
||||
if (t === 'none') return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
try {
|
||||
el.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
const p = toNorm(e);
|
||||
if (t === 'points') {
|
||||
commit('points', [p]);
|
||||
return;
|
||||
}
|
||||
if (t === 'polygon') {
|
||||
draftRef.current = draftRef.current ? [...draftRef.current, p] : [p];
|
||||
bump();
|
||||
return;
|
||||
}
|
||||
drawingRef.current = true;
|
||||
draftRef.current = t === 'bbox' ? [p, p] : [p];
|
||||
bump();
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!drawingRef.current || !draftRef.current) return;
|
||||
e.stopPropagation();
|
||||
const t = toolRef.current;
|
||||
const p = toNorm(e);
|
||||
const d = draftRef.current;
|
||||
draftRef.current = t === 'bbox' ? [d[0], p] : [...d, p];
|
||||
bump();
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!drawingRef.current) return;
|
||||
e.stopPropagation();
|
||||
drawingRef.current = false;
|
||||
const t = toolRef.current;
|
||||
const d = draftRef.current;
|
||||
if (d) {
|
||||
if (t === 'bbox') {
|
||||
const [a, b] = d;
|
||||
if (Math.abs(a.x - b.x) > 0.005 && Math.abs(a.y - b.y) > 0.005) {
|
||||
commit('bbox', [
|
||||
{ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
|
||||
{ x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
|
||||
]);
|
||||
}
|
||||
} else if (t === 'pen' && d.length > 1) commit('pen', d, PEN_WIDTH);
|
||||
else if (t === 'brush' && d.length > 1) commit('brush', d, BRUSH_WIDTH);
|
||||
}
|
||||
draftRef.current = null;
|
||||
bump();
|
||||
};
|
||||
const onDbl = (e: MouseEvent) => {
|
||||
const t = toolRef.current;
|
||||
if (t === 'none') return;
|
||||
e.stopPropagation();
|
||||
const d = draftRef.current;
|
||||
if (t === 'polygon' && d && d.length >= 3) {
|
||||
commit('polygon', d);
|
||||
draftRef.current = null;
|
||||
bump();
|
||||
} else {
|
||||
cbRef.current.onRequestExpand?.();
|
||||
}
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (toolRef.current === 'none') return;
|
||||
e.stopPropagation();
|
||||
cbRef.current.onForwardWheel?.({ deltaY: e.deltaY, ctrlKey: e.ctrlKey, metaKey: e.metaKey });
|
||||
};
|
||||
|
||||
el.addEventListener('pointerdown', onDown);
|
||||
el.addEventListener('pointermove', onMove);
|
||||
el.addEventListener('pointerup', onUp);
|
||||
el.addEventListener('pointerleave', onUp);
|
||||
el.addEventListener('dblclick', onDbl);
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener('pointerdown', onDown);
|
||||
el.removeEventListener('pointermove', onMove);
|
||||
el.removeEventListener('pointerup', onUp);
|
||||
el.removeEventListener('pointerleave', onUp);
|
||||
el.removeEventListener('dblclick', onDbl);
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}, [view, sliceIndex]);
|
||||
|
||||
const { w, h } = size;
|
||||
const draft = draftRef.current;
|
||||
const px = (p: AnnotationPoint) => `${p.x * w},${p.y * h}`;
|
||||
const onThisSlice = annotations.filter((a) => a.view === view && a.sliceIndex === sliceIndex);
|
||||
|
||||
const renderShape = (a: Annotation, key: string, live = false) => {
|
||||
const stroke = a.color || color;
|
||||
const dash = live ? '4 3' : undefined;
|
||||
if (a.tool === 'bbox' && a.points.length === 2) {
|
||||
const [tl, br] = a.points;
|
||||
return (
|
||||
<rect
|
||||
key={key}
|
||||
x={Math.min(tl.x, br.x) * w}
|
||||
y={Math.min(tl.y, br.y) * h}
|
||||
width={Math.abs(br.x - tl.x) * w}
|
||||
height={Math.abs(br.y - tl.y) * h}
|
||||
fill={`${stroke}22`}
|
||||
stroke={stroke}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={dash}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (a.tool === 'points') {
|
||||
return (
|
||||
<g key={key}>
|
||||
{a.points.map((p, i) => (
|
||||
<circle key={i} cx={p.x * w} cy={p.y * h} r={5} fill={stroke} stroke="#000" strokeWidth={1} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (a.tool === 'pen' || a.tool === 'brush') {
|
||||
return (
|
||||
<polyline
|
||||
key={key}
|
||||
points={a.points.map(px).join(' ')}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={a.strokeWidth ?? PEN_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={dash}
|
||||
opacity={a.tool === 'brush' ? 0.55 : 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (a.tool === 'polygon') {
|
||||
const Tag = live ? 'polyline' : 'polygon';
|
||||
return (
|
||||
<Tag key={key} points={a.points.map(px).join(' ')} fill={live ? 'none' : `${stroke}22`} stroke={stroke} strokeWidth={2} strokeDasharray={dash} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const draftAnno: Annotation | null =
|
||||
draft && tool !== 'none' && tool !== 'points'
|
||||
? { id: 'draft', view, sliceIndex, tool, points: draft, color, strokeWidth: tool === 'brush' ? BRUSH_WIDTH : PEN_WIDTH }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
pointerEvents: tool === 'none' ? 'none' : 'auto',
|
||||
cursor: tool === 'none' ? 'default' : 'crosshair',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<svg width={w} height={h} className="absolute inset-0" style={{ pointerEvents: 'none' }}>
|
||||
{onThisSlice.map((a) => renderShape(a, a.id))}
|
||||
{draftAnno && renderShape(draftAnno, 'draft', true)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,821 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import "@kitware/vtk.js/Rendering/Profiles/Volume";
|
||||
import "@kitware/vtk.js/Rendering/Profiles/Geometry";
|
||||
import "@kitware/vtk.js/Rendering/Misc/RenderingAPIs";
|
||||
|
||||
import vtkBoundingBox from "@kitware/vtk.js/Common/DataModel/BoundingBox";
|
||||
import vtkColorTransferFunction from "@kitware/vtk.js/Rendering/Core/ColorTransferFunction";
|
||||
import vtkImageResliceMapper from "@kitware/vtk.js/Rendering/Core/ImageResliceMapper";
|
||||
import vtkImageSlice from "@kitware/vtk.js/Rendering/Core/ImageSlice";
|
||||
import vtkInteractorStyleImage from "@kitware/vtk.js/Interaction/Style/InteractorStyleImage";
|
||||
import vtkInteractorStyleTrackballCamera from "@kitware/vtk.js/Interaction/Style/InteractorStyleTrackballCamera";
|
||||
import vtkPiecewiseFunction from "@kitware/vtk.js/Common/DataModel/PiecewiseFunction";
|
||||
import vtkPlane from "@kitware/vtk.js/Common/DataModel/Plane";
|
||||
import vtkRenderWindow from "@kitware/vtk.js/Rendering/Core/RenderWindow";
|
||||
import vtkRenderWindowInteractor from "@kitware/vtk.js/Rendering/Core/RenderWindowInteractor";
|
||||
import vtkRenderer from "@kitware/vtk.js/Rendering/Core/Renderer";
|
||||
import vtkVolume from "@kitware/vtk.js/Rendering/Core/Volume";
|
||||
import vtkVolumeMapper from "@kitware/vtk.js/Rendering/Core/VolumeMapper";
|
||||
import vtkOpenGLRenderWindow from "@kitware/vtk.js/Rendering/OpenGL/RenderWindow";
|
||||
|
||||
import type { ViewOrientation, Active2DView } from "./ViewRotationControls";
|
||||
|
||||
interface QuadViewRendererProps {
|
||||
dicomData: any;
|
||||
windowWidth: number;
|
||||
windowLevel: number;
|
||||
opacity: number;
|
||||
isLoading?: boolean;
|
||||
onRotate3D?: (orientation: ViewOrientation) => void;
|
||||
active2DView?: Active2DView;
|
||||
currentOrientation?: ViewOrientation;
|
||||
}
|
||||
|
||||
interface ViewportContainer {
|
||||
element: HTMLDivElement;
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type ExpandedView = null | 0 | 1 | 2 | 3;
|
||||
|
||||
export const QuadViewRenderer = ({
|
||||
dicomData,
|
||||
windowWidth,
|
||||
windowLevel,
|
||||
opacity,
|
||||
isLoading = false,
|
||||
onRotate3D,
|
||||
active2DView,
|
||||
currentOrientation,
|
||||
}: QuadViewRendererProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedView, setExpandedView] = useState<ExpandedView>(null);
|
||||
|
||||
const isMouseDownRef = useRef(false);
|
||||
const activeViewportRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Rotation angle state for each view
|
||||
const rotationAnglesRef = useRef<{ [key: string]: number }>({
|
||||
axial: 0,
|
||||
coronal: 0,
|
||||
sagittal: 0,
|
||||
});
|
||||
|
||||
const contextRef = useRef<{
|
||||
renderWindow: any;
|
||||
renderWindowView: any;
|
||||
interactor: any;
|
||||
renderers: any[];
|
||||
containers: ViewportContainer[];
|
||||
imageSliceActors: any[];
|
||||
slicePlanes: any[];
|
||||
volumeActor: any;
|
||||
ctf: any;
|
||||
pf: any;
|
||||
iStyle: any;
|
||||
tStyle: any;
|
||||
} | null>(null);
|
||||
|
||||
const loadedDataRef = useRef<any>(null);
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
const resizeViewportContainer = useCallback((
|
||||
_view: HTMLElement,
|
||||
ren: any,
|
||||
element: HTMLElement
|
||||
) => {
|
||||
const vp = ren.getViewport();
|
||||
const border = 2;
|
||||
// Position the frame as PERCENTAGES of the container. The WebGL canvas fills the
|
||||
// container (style width:100%), so a normalized VTK viewport [vp0,vp1,vp2,vp3] maps
|
||||
// to the same %-rect and lines up with the rendered content. Computing px from
|
||||
// getBoundingClientRect() was fragile: the viewer opens in a dialog that animates
|
||||
// with transform: scale(.95→1), and ResizeObserver does NOT fire on transform
|
||||
// changes — so a rect captured mid-zoom froze the frame ~5% small/offset while the
|
||||
// canvas stretched to fill, leaving the slice spilling past the top/right borders.
|
||||
// Percentages track the canvas under any transform or resize. (box-sizing:border-box
|
||||
// is set on the element, so the 2px frame is drawn inside the viewport rect.)
|
||||
element.style.position = "absolute";
|
||||
element.style.left = `${vp[0] * 100}%`;
|
||||
element.style.bottom = `${vp[1] * 100}%`;
|
||||
element.style.width = `${(vp[2] - vp[0]) * 100}%`;
|
||||
element.style.height = `${(vp[3] - vp[1]) * 100}%`;
|
||||
element.style.border = `solid ${border}px hsl(var(--border))`;
|
||||
element.style.borderRadius = "6px";
|
||||
}, []);
|
||||
|
||||
const bindInteractor = useCallback((interactor: any, el: HTMLElement | null) => {
|
||||
if (!contextRef.current || !containerRef.current) return;
|
||||
const { iStyle, tStyle } = contextRef.current;
|
||||
const full = containerRef.current;
|
||||
|
||||
// Switch interaction STYLE per active pane (3D → trackball, 2D → image), but
|
||||
// always bind events to the full canvas container — never a per-pane overlay —
|
||||
// so VTK's findPokedRenderer resolves the cursor to the correct viewport in
|
||||
// full-canvas coordinates (sub-region binding made 3D drags drive the 2D panes).
|
||||
if (el) {
|
||||
interactor.setInteractorStyle(el.dataset.viewId === "3" ? tStyle : iStyle);
|
||||
}
|
||||
if (interactor.getContainer() !== full) {
|
||||
if (interactor.getContainer()) {
|
||||
interactor.unbindEvents();
|
||||
}
|
||||
interactor.bindEvents(full);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getViewports = useCallback((expanded: ExpandedView): [number, number, number, number][] => {
|
||||
if (expanded === null) {
|
||||
return [
|
||||
[0.01, 0.51, 0.49, 0.99], // top-left (Axial)
|
||||
[0.51, 0.51, 0.99, 0.99], // top-right (Coronal)
|
||||
[0.01, 0.01, 0.49, 0.49], // bottom-left (Sagittal)
|
||||
[0.51, 0.01, 0.99, 0.49], // bottom-right (3D)
|
||||
];
|
||||
} else {
|
||||
const mainViewport: [number, number, number, number] = [0.01, 0.01, 0.74, 0.99];
|
||||
const sideViewports: [number, number, number, number][] = [
|
||||
[0.75, 0.67, 0.99, 0.99],
|
||||
[0.75, 0.34, 0.99, 0.66],
|
||||
[0.75, 0.01, 0.99, 0.33],
|
||||
];
|
||||
|
||||
const result: [number, number, number, number][] = [];
|
||||
let sideIndex = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (i === expanded) {
|
||||
result.push(mainViewport);
|
||||
} else {
|
||||
result.push(sideViewports[sideIndex]);
|
||||
sideIndex++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resize = useCallback(() => {
|
||||
if (!containerRef.current || !contextRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const { renderWindowView, renderers, containers, renderWindow } = contextRef.current;
|
||||
|
||||
renderWindowView.setSize(rect.width, rect.height);
|
||||
|
||||
containers.forEach((c) => {
|
||||
resizeViewportContainer(containerRef.current!, renderers[c.id], c.element);
|
||||
});
|
||||
|
||||
renderWindow.render();
|
||||
}, [resizeViewportContainer]);
|
||||
|
||||
// --- Logic 1: 3D Rotation Functionality ---
|
||||
|
||||
const rotate3DView = useCallback((orientation: ViewOrientation) => {
|
||||
if (!contextRef.current || !dicomData?.imageData) return;
|
||||
|
||||
const { renderers, renderWindow } = contextRef.current;
|
||||
const bounds = dicomData.imageData.getBounds();
|
||||
const center = vtkBoundingBox.getCenter(bounds);
|
||||
const diagonal = vtkBoundingBox.getDiagonalLength(bounds);
|
||||
const distance = (diagonal ?? 0) * 1.5;
|
||||
|
||||
const cam3d = renderers[3].getActiveCamera();
|
||||
|
||||
switch (orientation) {
|
||||
case "anterior":
|
||||
cam3d.setPosition(center[0], center[1] - distance, center[2]);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 0, 1);
|
||||
break;
|
||||
case "posterior":
|
||||
cam3d.setPosition(center[0], center[1] + distance, center[2]);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 0, 1);
|
||||
break;
|
||||
case "left-lateral":
|
||||
cam3d.setPosition(center[0] - distance, center[1], center[2]);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 0, 1);
|
||||
break;
|
||||
case "right-lateral":
|
||||
cam3d.setPosition(center[0] + distance, center[1], center[2]);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 0, 1);
|
||||
break;
|
||||
case "superior":
|
||||
cam3d.setPosition(center[0], center[1], center[2] + distance);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 1, 0);
|
||||
break;
|
||||
case "inferior":
|
||||
cam3d.setPosition(center[0], center[1], center[2] - distance);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, -1, 0);
|
||||
break;
|
||||
default:
|
||||
cam3d.setPosition(center[0], center[1] - distance, center[2]);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 0, 1);
|
||||
}
|
||||
|
||||
renderWindow.render();
|
||||
onRotate3D?.(orientation);
|
||||
}, [dicomData, onRotate3D]);
|
||||
|
||||
// --- Logic 2: Robust 2D Rotation Logic (Rodrigues' Formula) ---
|
||||
const rotate2DView = useCallback((view: Active2DView, direction: "cw" | "ccw") => {
|
||||
if (!contextRef.current || !view || !dicomData?.imageData) return;
|
||||
|
||||
const { renderers, renderWindow } = contextRef.current;
|
||||
const bounds = dicomData.imageData.getBounds();
|
||||
const center = vtkBoundingBox.getCenter(bounds);
|
||||
|
||||
// Rotation step in degrees
|
||||
const rotationStep = direction === "cw" ? 90 : -90;
|
||||
|
||||
// Update rotation angle
|
||||
rotationAnglesRef.current[view] = (rotationAnglesRef.current[view] + rotationStep) % 360;
|
||||
const angle = rotationAnglesRef.current[view] * (Math.PI / 180);
|
||||
|
||||
let cam: any;
|
||||
let viewDirection: [number, number, number];
|
||||
let baseViewUp: [number, number, number];
|
||||
|
||||
// Get the appropriate renderer and base view up vector
|
||||
if (view === "axial") {
|
||||
cam = renderers[0].getActiveCamera();
|
||||
viewDirection = [0, 0, 1]; // Looking along Z axis
|
||||
baseViewUp = [0, -1, 0];
|
||||
} else if (view === "coronal") {
|
||||
cam = renderers[1].getActiveCamera();
|
||||
viewDirection = [0, 1, 0]; // Looking along Y axis
|
||||
baseViewUp = [0, 0, 1];
|
||||
} else if (view === "sagittal") {
|
||||
cam = renderers[2].getActiveCamera();
|
||||
viewDirection = [1, 0, 0]; // Looking along X axis
|
||||
baseViewUp = [0, 0, 1];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current view up vector from camera
|
||||
const currentViewUp = cam.getViewUp();
|
||||
|
||||
// Calculate rotation axis (the view direction)
|
||||
const axis = viewDirection;
|
||||
|
||||
// Rotate the current view up vector around the view direction axis
|
||||
// Using Rodrigues' rotation formula
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const dot = currentViewUp[0] * axis[0] + currentViewUp[1] * axis[1] + currentViewUp[2] * axis[2];
|
||||
|
||||
// Project view up onto the plane perpendicular to the axis
|
||||
const projX = currentViewUp[0] - dot * axis[0];
|
||||
const projY = currentViewUp[1] - dot * axis[1];
|
||||
const projZ = currentViewUp[2] - dot * axis[2];
|
||||
|
||||
// Normalize the projection
|
||||
const projLen = Math.sqrt(projX * projX + projY * projY + projZ * projZ);
|
||||
if (projLen < 1e-6) {
|
||||
// If projection is too small, use base view up
|
||||
const rotatedViewUp: [number, number, number] = [
|
||||
baseViewUp[0] * cos - (axis[1] * baseViewUp[2] - axis[2] * baseViewUp[1]) * sin,
|
||||
baseViewUp[1] * cos - (axis[2] * baseViewUp[0] - axis[0] * baseViewUp[2]) * sin,
|
||||
baseViewUp[2] * cos - (axis[0] * baseViewUp[1] - axis[1] * baseViewUp[0]) * sin
|
||||
];
|
||||
cam.setFocalPoint(...center);
|
||||
cam.setViewUp(...rotatedViewUp);
|
||||
} else {
|
||||
// Normalize projection
|
||||
const ux = projX / projLen;
|
||||
const uy = projY / projLen;
|
||||
const uz = projZ / projLen;
|
||||
|
||||
// Calculate perpendicular vector (cross product of axis and projection)
|
||||
const perpX = axis[1] * uz - axis[2] * uy;
|
||||
const perpY = axis[2] * ux - axis[0] * uz;
|
||||
const perpZ = axis[0] * uy - axis[1] * ux;
|
||||
|
||||
// Rotate using Rodrigues' formula
|
||||
const rotatedViewUp: [number, number, number] = [
|
||||
ux * cos + perpX * sin + dot * axis[0],
|
||||
uy * cos + perpY * sin + dot * axis[1],
|
||||
uz * cos + perpZ * sin + dot * axis[2]
|
||||
];
|
||||
|
||||
cam.setFocalPoint(...center);
|
||||
cam.setViewUp(...rotatedViewUp);
|
||||
}
|
||||
|
||||
cam.orthogonalizeViewUp();
|
||||
renderWindow.render();
|
||||
}, [dicomData]);
|
||||
|
||||
// --- Logic 3: Handle currentOrientation prop changes ---
|
||||
useEffect(() => {
|
||||
if (!contextRef.current || !currentOrientation || !active2DView) return;
|
||||
|
||||
// This handles 2D rotations triggered by the toolbar/external state
|
||||
if (currentOrientation === "rotate-cw") {
|
||||
rotate2DView(active2DView, "cw");
|
||||
} else if (currentOrientation === "rotate-ccw") {
|
||||
rotate2DView(active2DView, "ccw");
|
||||
}
|
||||
}, [currentOrientation, active2DView, rotate2DView]);
|
||||
|
||||
// --- Logic 4: Standard Medical View Orientations (2D Initialization) ---
|
||||
useEffect(() => {
|
||||
if (!contextRef.current || !dicomData?.imageData) return;
|
||||
|
||||
const { renderers, renderWindow } = contextRef.current;
|
||||
const bounds = dicomData.imageData.getBounds();
|
||||
const center = vtkBoundingBox.getCenter(bounds);
|
||||
|
||||
// Axial: Looking from head to toe, patient's right on left (Radiological)
|
||||
const axialCam = renderers[0].getActiveCamera();
|
||||
axialCam.setPosition(center[0], center[1], center[2] - 1);
|
||||
axialCam.setFocalPoint(center[0], center[1], center[2]);
|
||||
axialCam.setViewUp(0, -1, 0); // Inverted Y for radiological convention
|
||||
|
||||
// Coronal: Looking from front to back, patient's right on left
|
||||
const coronalCam = renderers[1].getActiveCamera();
|
||||
coronalCam.setPosition(center[0], center[1] - 1, center[2]);
|
||||
coronalCam.setFocalPoint(center[0], center[1], center[2]);
|
||||
coronalCam.setViewUp(0, 0, 1);
|
||||
|
||||
// Sagittal: Looking from left to right, anterior at top
|
||||
const sagittalCam = renderers[2].getActiveCamera();
|
||||
sagittalCam.setPosition(center[0] - 1, center[1], center[2]);
|
||||
sagittalCam.setFocalPoint(center[0], center[1], center[2]);
|
||||
sagittalCam.setViewUp(0, 0, 1);
|
||||
|
||||
renderWindow.render();
|
||||
}, [dicomData]);
|
||||
|
||||
// --- Logic 5: Handle External Rotation Requests via Ref (Imperative) ---
|
||||
useEffect(() => {
|
||||
if (!onRotate3D || !contextRef.current) return;
|
||||
|
||||
const handleRotate = (orientation: ViewOrientation) => {
|
||||
// Integrated Logic from Code 1: 2D Rotations
|
||||
if (orientation === "rotate-cw" && active2DView) {
|
||||
rotate2DView(active2DView, "cw");
|
||||
} else if (orientation === "rotate-ccw" && active2DView) {
|
||||
rotate2DView(active2DView, "ccw");
|
||||
} else if (orientation === "axial" || orientation === "coronal" || orientation === "sagittal") {
|
||||
// Reset rotation angle for that view if explicitly selected
|
||||
rotationAnglesRef.current[orientation] = 0;
|
||||
} else {
|
||||
// Handle 3D rotations
|
||||
rotate3DView(orientation);
|
||||
}
|
||||
};
|
||||
|
||||
// Store handler for external access
|
||||
(contextRef.current as any).handleRotate = handleRotate;
|
||||
(contextRef.current as any).rotate3D = rotate3DView;
|
||||
}, [onRotate3D, rotate3DView, rotate2DView, active2DView]);
|
||||
|
||||
// --- Logic 6: Reset rotation when view changes ---
|
||||
useEffect(() => {
|
||||
if (active2DView) {
|
||||
rotationAnglesRef.current[active2DView] = 0;
|
||||
}
|
||||
}, [active2DView]);
|
||||
|
||||
// --- Logic 7: Viewport Expansion Handling ---
|
||||
useEffect(() => {
|
||||
if (!contextRef.current) return;
|
||||
|
||||
const { renderers, containers, renderWindow, renderWindowView } = contextRef.current;
|
||||
const viewports = getViewports(expandedView);
|
||||
|
||||
renderers.forEach((ren, i) => {
|
||||
ren.setViewport(viewports[i][0], viewports[i][1], viewports[i][2], viewports[i][3]);
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
renderWindowView.setSize(rect.width, rect.height);
|
||||
|
||||
containers.forEach((c) => {
|
||||
resizeViewportContainer(containerRef.current!, renderers[c.id], c.element);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-fit every view to its new viewport so the slice / volume FILLS the resized pane.
|
||||
// Without this an expanded pane keeps the small framing it had as a 2×2 quadrant
|
||||
// (content stuck tiny in a corner). resetCamera preserves each camera's direction +
|
||||
// view-up — so orientation and any 3D rotation are kept — and only re-centers + rescales.
|
||||
renderers.forEach((ren) => ren.resetCamera());
|
||||
|
||||
renderWindow.render();
|
||||
}, [expandedView, getViewports, resizeViewportContainer]);
|
||||
|
||||
// --- Logic 8: VTK Initialization ---
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const renderWindow = vtkRenderWindow.newInstance();
|
||||
const renderWindowView = vtkOpenGLRenderWindow.newInstance();
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
renderWindowView.setSize(rect.width, rect.height);
|
||||
renderWindow.addView(renderWindowView);
|
||||
renderWindowView.setContainer(containerRef.current);
|
||||
|
||||
const iStyle = vtkInteractorStyleImage.newInstance();
|
||||
const tStyle = vtkInteractorStyleTrackballCamera.newInstance();
|
||||
|
||||
const interactor = vtkRenderWindowInteractor.newInstance();
|
||||
interactor.setView(renderWindowView);
|
||||
interactor.initialize();
|
||||
interactor.setInteractorStyle(tStyle);
|
||||
|
||||
const renderers: any[] = [];
|
||||
const containers: ViewportContainer[] = [];
|
||||
const labels = ["AXIAL", "CORONAL", "SAGITTAL", "3D"];
|
||||
|
||||
// Create 4 viewports in a 2x2 grid
|
||||
const viewports: [number, number, number, number][] = [
|
||||
[0.01, 0.51, 0.49, 0.99], // top-left
|
||||
[0.51, 0.51, 0.99, 0.99], // top-right
|
||||
[0.01, 0.01, 0.49, 0.49], // bottom-left
|
||||
[0.51, 0.01, 0.99, 0.49], // bottom-right (3D)
|
||||
];
|
||||
|
||||
// Pin / unpin all VTK interaction to a single pane for the lifetime of a drag.
|
||||
// VTK re-resolves the "poked" renderer on EVERY mouse-move (findPokedRenderer),
|
||||
// and it skips renderers whose getInteractive() is false — so marking the other
|
||||
// panes non-interactive while a drag is in flight keeps the gesture on the pane
|
||||
// it started in (e.g. a 3D-pane rotate never leaks into axial/coronal/sagittal).
|
||||
const confineInteractionTo = (activeId: number) => {
|
||||
renderers.forEach((ren, idx) => ren.setInteractive(idx === activeId));
|
||||
};
|
||||
const releaseInteraction = () => {
|
||||
renderers.forEach((ren) => ren.setInteractive(true));
|
||||
};
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// Black background for 2D views, dark gray for 3D
|
||||
const bgColor: [number, number, number] = i === 3 ? [0.1, 0.1, 0.15] : [0, 0, 0];
|
||||
const ren = vtkRenderer.newInstance({ background: bgColor });
|
||||
ren.setViewport(viewports[i][0], viewports[i][1], viewports[i][2], viewports[i][3]);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.dataset.viewId = String(i);
|
||||
container.style.position = "absolute";
|
||||
container.style.boxSizing = "border-box";
|
||||
container.style.overflow = "hidden";
|
||||
container.style.borderRadius = "6px";
|
||||
|
||||
containerRef.current.appendChild(container);
|
||||
|
||||
// Add label
|
||||
const label = document.createElement("div");
|
||||
label.textContent = labels[i];
|
||||
label.style.position = "absolute";
|
||||
label.style.top = "8px";
|
||||
label.style.left = "8px";
|
||||
label.style.color = "rgba(255, 255, 255, 0.7)";
|
||||
label.style.fontSize = "11px";
|
||||
label.style.fontWeight = "600";
|
||||
label.style.letterSpacing = "0.05em";
|
||||
label.style.textTransform = "uppercase";
|
||||
label.style.zIndex = "10";
|
||||
label.style.pointerEvents = "none";
|
||||
label.style.textShadow = "0 1px 2px rgba(0,0,0,0.8)";
|
||||
container.appendChild(label);
|
||||
|
||||
// Handle mouse down - lock interactor to this viewport
|
||||
container.addEventListener("mousedown", (e) => {
|
||||
isMouseDownRef.current = true;
|
||||
activeViewportRef.current = container;
|
||||
confineInteractionTo(Number(container.dataset.viewId));
|
||||
bindInteractor(interactor, container);
|
||||
// Toggle expand on double-click — detected HERE via e.detail===2 (the 2nd press of
|
||||
// a double-click) rather than a "dblclick" listener, because VTK's interactor takes
|
||||
// pointer capture on press, so the native click/dblclick land on the parent canvas
|
||||
// container, NOT this per-pane div. mousedown still fires on the div, so this is the
|
||||
// only place a per-pane double-click is observable.
|
||||
if (e.detail === 2) {
|
||||
const viewId = Number(container.dataset.viewId);
|
||||
setExpandedView((prev) => (prev === viewId ? null : (viewId as ExpandedView)));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle mouse up - unlock interactor
|
||||
container.addEventListener("mouseup", () => {
|
||||
isMouseDownRef.current = false;
|
||||
activeViewportRef.current = null;
|
||||
releaseInteraction();
|
||||
});
|
||||
|
||||
// Handle mouse leave - only unbind if not dragging
|
||||
container.addEventListener("mouseleave", () => {
|
||||
if (!isMouseDownRef.current) {
|
||||
bindInteractor(interactor, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle pointer enter - only bind if not dragging and no active viewport
|
||||
container.addEventListener("pointerenter", () => {
|
||||
if (!isMouseDownRef.current && !activeViewportRef.current) {
|
||||
bindInteractor(interactor, container);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle pointer leave - only unbind if not dragging
|
||||
container.addEventListener("pointerleave", () => {
|
||||
if (!isMouseDownRef.current) {
|
||||
bindInteractor(interactor, null);
|
||||
}
|
||||
});
|
||||
|
||||
renderWindow.addRenderer(ren);
|
||||
renderers.push(ren);
|
||||
containers.push({ element: container, id: i, label: labels[i] });
|
||||
|
||||
resizeViewportContainer(containerRef.current, ren, container);
|
||||
}
|
||||
|
||||
// Handle global mouse up to catch releases outside viewports
|
||||
const handleGlobalMouseUp = () => {
|
||||
isMouseDownRef.current = false;
|
||||
activeViewportRef.current = null;
|
||||
releaseInteraction();
|
||||
};
|
||||
window.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
|
||||
// Create slice mappers and planes
|
||||
const slicePlanes: any[] = [];
|
||||
const imageSliceActors: any[] = [];
|
||||
|
||||
// Axial plane (Z normal)
|
||||
const axialPlane = vtkPlane.newInstance();
|
||||
axialPlane.setNormal(0, 0, 1);
|
||||
slicePlanes.push(axialPlane);
|
||||
|
||||
// Coronal plane (Y normal)
|
||||
const coronalPlane = vtkPlane.newInstance();
|
||||
coronalPlane.setNormal(0, 1, 0);
|
||||
slicePlanes.push(coronalPlane);
|
||||
|
||||
// Sagittal plane (X normal)
|
||||
const sagittalPlane = vtkPlane.newInstance();
|
||||
sagittalPlane.setNormal(1, 0, 0);
|
||||
slicePlanes.push(sagittalPlane);
|
||||
|
||||
// Create 2D slice actors (grayscale; window/level driven)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const mapper = vtkImageResliceMapper.newInstance();
|
||||
mapper.setSlicePlane(slicePlanes[i]);
|
||||
|
||||
const actor = vtkImageSlice.newInstance();
|
||||
actor.setMapper(mapper);
|
||||
|
||||
const sliceCtf = vtkColorTransferFunction.newInstance();
|
||||
// Placeholder points; updated by window/level effect
|
||||
sliceCtf.addRGBPoint(0, 0, 0, 0);
|
||||
sliceCtf.addRGBPoint(1, 1, 1, 1);
|
||||
|
||||
actor.getProperty().setRGBTransferFunction(0, sliceCtf);
|
||||
actor.getProperty().setColorWindow(windowWidth);
|
||||
actor.getProperty().setColorLevel(windowLevel);
|
||||
|
||||
renderers[i].addActor(actor);
|
||||
|
||||
const cam = renderers[i].getActiveCamera();
|
||||
cam.setParallelProjection(true);
|
||||
|
||||
if (i === 0) {
|
||||
// Axial
|
||||
cam.setPosition(0, 0, -1);
|
||||
cam.setViewUp(0, -1, 0);
|
||||
} else if (i === 1) {
|
||||
// Coronal
|
||||
cam.setPosition(0, -1, 0);
|
||||
cam.setViewUp(0, 0, 1);
|
||||
} else {
|
||||
// Sagittal
|
||||
cam.setPosition(-1, 0, 0);
|
||||
cam.setViewUp(0, 0, 1);
|
||||
}
|
||||
|
||||
imageSliceActors.push({ actor, mapper, ctf: sliceCtf });
|
||||
}
|
||||
|
||||
// Volume actor for 3D view with color transfer function
|
||||
const ctf = vtkColorTransferFunction.newInstance();
|
||||
ctf.addRGBPoint(-1000, 0, 0, 0);
|
||||
ctf.addRGBPoint(-100, 0.4, 0.2, 0.1);
|
||||
ctf.addRGBPoint(0, 0.8, 0.6, 0.5);
|
||||
ctf.addRGBPoint(100, 0.9, 0.8, 0.7);
|
||||
ctf.addRGBPoint(400, 1, 1, 0.9);
|
||||
ctf.addRGBPoint(1000, 1, 1, 1);
|
||||
|
||||
const pf = vtkPiecewiseFunction.newInstance();
|
||||
pf.addPoint(-1000, 0.0);
|
||||
pf.addPoint(-100, 0.0);
|
||||
pf.addPoint(0, 0.1);
|
||||
pf.addPoint(100, 0.3);
|
||||
pf.addPoint(400, 0.6);
|
||||
pf.addPoint(1000, 1.0);
|
||||
|
||||
const volumeActor = vtkVolume.newInstance();
|
||||
const volumeMapper = vtkVolumeMapper.newInstance({ sampleDistance: 1.0 });
|
||||
|
||||
volumeActor.setMapper(volumeMapper);
|
||||
volumeActor.getProperty().setRGBTransferFunction(0, ctf);
|
||||
volumeActor.getProperty().setScalarOpacity(0, pf);
|
||||
volumeActor.getProperty().setScalarOpacityUnitDistance(0, 3.0);
|
||||
volumeActor.getProperty().setInterpolationTypeToLinear();
|
||||
volumeActor.getProperty().setUseGradientOpacity(0, true);
|
||||
volumeActor.getProperty().setGradientOpacityMinimumValue(0, 2);
|
||||
volumeActor.getProperty().setGradientOpacityMinimumOpacity(0, 0.0);
|
||||
volumeActor.getProperty().setGradientOpacityMaximumValue(0, 20);
|
||||
volumeActor.getProperty().setGradientOpacityMaximumOpacity(0, 1.0);
|
||||
volumeActor.getProperty().setShade(true);
|
||||
volumeActor.getProperty().setAmbient(0.2);
|
||||
volumeActor.getProperty().setDiffuse(0.7);
|
||||
volumeActor.getProperty().setSpecular(0.3);
|
||||
volumeActor.getProperty().setSpecularPower(8.0);
|
||||
|
||||
contextRef.current = {
|
||||
renderWindow,
|
||||
renderWindowView,
|
||||
interactor,
|
||||
renderers,
|
||||
containers,
|
||||
imageSliceActors,
|
||||
slicePlanes,
|
||||
volumeActor: { actor: volumeActor, mapper: volumeMapper },
|
||||
ctf,
|
||||
pf,
|
||||
iStyle,
|
||||
tStyle,
|
||||
};
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
|
||||
if (contextRef.current) {
|
||||
containers.forEach((c) => c.element.remove());
|
||||
|
||||
imageSliceActors.forEach(({ actor, mapper, ctf: sliceCtf }) => {
|
||||
actor.delete();
|
||||
mapper.delete();
|
||||
sliceCtf?.delete();
|
||||
});
|
||||
|
||||
slicePlanes.forEach((p) => p.delete());
|
||||
volumeActor.delete();
|
||||
volumeMapper.delete();
|
||||
ctf.delete();
|
||||
pf.delete();
|
||||
renderers.forEach((r) => r.delete());
|
||||
interactor.delete();
|
||||
renderWindowView.delete();
|
||||
renderWindow.delete();
|
||||
contextRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [bindInteractor, resize, resizeViewportContainer]);
|
||||
|
||||
// --- Logic 9: Window/Level Updates ---
|
||||
useEffect(() => {
|
||||
if (!contextRef.current) return;
|
||||
|
||||
const { imageSliceActors, volumeActor, ctf, pf, renderWindow } = contextRef.current;
|
||||
|
||||
const low = windowLevel - windowWidth / 2;
|
||||
const high = windowLevel + windowWidth / 2;
|
||||
|
||||
// 2D: strict grayscale with black background
|
||||
imageSliceActors.forEach(({ actor, ctf: sliceCtf }) => {
|
||||
actor.getProperty().setColorWindow(windowWidth);
|
||||
actor.getProperty().setColorLevel(windowLevel);
|
||||
|
||||
// Anything below low stays black; anything above high stays white
|
||||
sliceCtf.removeAllPoints();
|
||||
sliceCtf.addRGBPoint(low - 1, 0, 0, 0);
|
||||
sliceCtf.addRGBPoint(low, 0, 0, 0);
|
||||
sliceCtf.addRGBPoint(high, 1, 1, 1);
|
||||
sliceCtf.addRGBPoint(high + 1, 1, 1, 1);
|
||||
|
||||
actor.getProperty().setRGBTransferFunction(0, sliceCtf);
|
||||
});
|
||||
|
||||
// 3D: window/level driven (keeps opacity)
|
||||
ctf.removeAllPoints();
|
||||
ctf.addRGBPoint(low - 200, 0, 0, 0);
|
||||
ctf.addRGBPoint(low, 0.4, 0.2, 0.1);
|
||||
ctf.addRGBPoint(low + (high - low) * 0.3, 0.8, 0.6, 0.5);
|
||||
ctf.addRGBPoint(low + (high - low) * 0.5, 0.9, 0.8, 0.7);
|
||||
ctf.addRGBPoint(high, 1, 1, 0.9);
|
||||
ctf.addRGBPoint(high + 200, 1, 1, 1);
|
||||
|
||||
pf.removeAllPoints();
|
||||
pf.addPoint(low - 200, 0);
|
||||
pf.addPoint(low, 0);
|
||||
pf.addPoint(low + (high - low) * 0.2, opacity * 0.2);
|
||||
pf.addPoint(low + (high - low) * 0.5, opacity * 0.5);
|
||||
pf.addPoint(high, opacity);
|
||||
|
||||
volumeActor.actor.getProperty().setRGBTransferFunction(0, ctf);
|
||||
volumeActor.actor.getProperty().setScalarOpacity(0, pf);
|
||||
|
||||
renderWindow.render();
|
||||
}, [windowWidth, windowLevel, opacity]);
|
||||
|
||||
// --- Logic 10: Data Loading ---
|
||||
useEffect(() => {
|
||||
if (!dicomData?.imageData || !contextRef.current) return;
|
||||
|
||||
if (loadedDataRef.current === dicomData.imageData) return;
|
||||
loadedDataRef.current = dicomData.imageData;
|
||||
|
||||
const { imageSliceActors, slicePlanes, volumeActor, renderers, renderWindow, ctf, pf } = contextRef.current;
|
||||
|
||||
const im = dicomData.imageData;
|
||||
const bounds = im.getBounds();
|
||||
|
||||
// Set slice plane origins at center of volume
|
||||
const centerX = (bounds[0] + bounds[1]) / 2;
|
||||
const centerY = (bounds[2] + bounds[3]) / 2;
|
||||
const centerZ = (bounds[4] + bounds[5]) / 2;
|
||||
|
||||
slicePlanes[0].setOrigin(centerX, centerY, centerZ); // Axial
|
||||
slicePlanes[1].setOrigin(centerX, centerY, centerZ); // Coronal
|
||||
slicePlanes[2].setOrigin(centerX, centerY, centerZ); // Sagittal
|
||||
|
||||
// Set input data for 2D slices
|
||||
imageSliceActors.forEach(({ mapper }) => {
|
||||
mapper.setInputData(im);
|
||||
});
|
||||
|
||||
// Set input data for 3D volume
|
||||
volumeActor.mapper.setInputData(im);
|
||||
|
||||
// Add volume to 3D renderer
|
||||
renderers[3].removeAllActors();
|
||||
renderers[3].removeAllVolumes();
|
||||
renderers[3].addVolume(volumeActor.actor);
|
||||
|
||||
// Configure volume properties
|
||||
const dataArray = im.getPointData().getScalars();
|
||||
const dataRange = dataArray.getRange();
|
||||
|
||||
volumeActor.actor.getProperty().setScalarOpacityUnitDistance(
|
||||
0,
|
||||
(vtkBoundingBox.getDiagonalLength(bounds) ?? 0) / Math.max(...im.getDimensions())
|
||||
);
|
||||
volumeActor.actor.getProperty().setGradientOpacityMinimumValue(0, 0);
|
||||
volumeActor.actor.getProperty().setGradientOpacityMaximumValue(0, (dataRange[1] - dataRange[0]) * 0.05);
|
||||
volumeActor.actor.getProperty().setRGBTransferFunction(0, ctf);
|
||||
volumeActor.actor.getProperty().setScalarOpacity(0, pf);
|
||||
|
||||
// Set 3D camera
|
||||
const center = vtkBoundingBox.getCenter(bounds);
|
||||
const cam3d = renderers[3].getActiveCamera();
|
||||
cam3d.setPosition(center[0], center[1] - 500, center[2]);
|
||||
cam3d.setFocalPoint(...center);
|
||||
cam3d.setViewUp(0, 0, 1);
|
||||
|
||||
// Reset cameras
|
||||
renderers.forEach((r) => r.resetCamera());
|
||||
|
||||
renderWindow.render();
|
||||
}, [dicomData]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full" ref={containerRef}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Loading DICOM data...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dicomData && !isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||||
<div className="text-center p-8">
|
||||
<div className="text-6xl mb-4 opacity-30">🩻</div>
|
||||
<p className="text-muted-foreground">Upload DICOM files to view</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useMemo } from "react";
|
||||
import { QuadViewRenderer } from "./QuadViewRenderer";
|
||||
import { NiftiQuadViewRenderer } from "./NiftiQuadViewRenderer";
|
||||
import { useDicomData } from "./useDicomData";
|
||||
import { useNiftiData } from "./useNiftiData";
|
||||
// Updated import to include Active2DView
|
||||
import type { ViewOrientation, Active2DView } from "./ViewRotationControls";
|
||||
import type { BoundingBox, SegmentationMask } from "./types";
|
||||
import type { OrganMaskData } from "./types";
|
||||
import type { Annotation, AnnotationTool } from "./types";
|
||||
|
||||
export type FileFormat = "dicom" | "nifti" | null;
|
||||
|
||||
export interface UnifiedQuadViewRendererProps {
|
||||
files: File[];
|
||||
windowWidth: number;
|
||||
windowLevel: number;
|
||||
opacity: number;
|
||||
isLoading?: boolean;
|
||||
onRotate3D?: (orientation: ViewOrientation) => void;
|
||||
active2DView?: Active2DView;
|
||||
currentOrientation?: ViewOrientation;
|
||||
// Segmentation props
|
||||
segmentationEnabled?: boolean;
|
||||
boundingBox?: BoundingBox | null;
|
||||
onBoundingBoxChange?: (box: BoundingBox | null) => void;
|
||||
currentSliceIndex?: number;
|
||||
onSliceIndexChange?: (index: number) => void;
|
||||
segmentationMask?: SegmentationMask | null;
|
||||
// Organ mask overlays
|
||||
organMasks?: OrganMaskData[];
|
||||
// Client-side annotation tools (bbox / points / pen / brush / polygon)
|
||||
annotationTool?: AnnotationTool;
|
||||
annotations?: Annotation[];
|
||||
onAnnotationsChange?: (annotations: Annotation[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects file format based on file extensions
|
||||
*/
|
||||
const detectFileFormat = (files: File[]): FileFormat => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Check if any file is NIfTI
|
||||
const hasNifti = files.some(file => {
|
||||
const name = file.name.toLowerCase();
|
||||
return name.endsWith(".nii") || name.endsWith(".nii.gz");
|
||||
});
|
||||
|
||||
if (hasNifti) {
|
||||
// If there's a mix, prioritize NIfTI if it's a single file
|
||||
if (files.length === 1) return "nifti";
|
||||
// If multiple files and one is NIfTI, still treat as NIfTI (use first NIfTI file)
|
||||
return "nifti";
|
||||
}
|
||||
|
||||
// Check if files are DICOM
|
||||
const hasDicom = files.some(file => {
|
||||
const name = file.name.toLowerCase();
|
||||
return name.endsWith(".dcm") || name.endsWith(".dicom");
|
||||
});
|
||||
|
||||
if (hasDicom) return "dicom";
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified Quad View Renderer that automatically switches between
|
||||
* DICOM and NIfTI renderers based on uploaded file format
|
||||
*/
|
||||
export const UnifiedQuadViewRenderer = ({
|
||||
files,
|
||||
windowWidth,
|
||||
windowLevel,
|
||||
opacity,
|
||||
isLoading: externalLoading = false,
|
||||
onRotate3D,
|
||||
active2DView,
|
||||
currentOrientation,
|
||||
// Segmentation props
|
||||
segmentationEnabled = false,
|
||||
boundingBox,
|
||||
onBoundingBoxChange,
|
||||
currentSliceIndex = 0,
|
||||
onSliceIndexChange,
|
||||
segmentationMask,
|
||||
// Organ mask overlays
|
||||
organMasks = [],
|
||||
annotationTool = "none",
|
||||
annotations = [],
|
||||
onAnnotationsChange,
|
||||
}: UnifiedQuadViewRendererProps) => {
|
||||
// Detect file format
|
||||
const fileFormat = useMemo(() => detectFileFormat(files), [files]);
|
||||
|
||||
// Get the first NIfTI file if format is NIfTI
|
||||
// Use a more specific key to ensure changes are detected
|
||||
const niftiFile = useMemo(() => {
|
||||
if (fileFormat === "nifti") {
|
||||
const niftiFile = files.find(file => {
|
||||
const name = file.name.toLowerCase();
|
||||
return name.endsWith(".nii") || name.endsWith(".nii.gz");
|
||||
});
|
||||
return niftiFile || null;
|
||||
}
|
||||
return null;
|
||||
}, [files, fileFormat]);
|
||||
|
||||
// Get DICOM files if format is DICOM - filter out any NIfTI files that might be mixed in
|
||||
const dicomFileObjects = useMemo(() => {
|
||||
if (fileFormat === "dicom") {
|
||||
return files.filter(file => {
|
||||
const name = file.name.toLowerCase();
|
||||
return name.endsWith(".dcm") || name.endsWith(".dicom");
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [files, fileFormat]);
|
||||
|
||||
// Use appropriate hooks based on file format
|
||||
const { dicomData, isLoading: isDicomLoading } = useDicomData(dicomFileObjects);
|
||||
const { niftiData, isLoading: isNiftiLoading, error: niftiError } = useNiftiData(niftiFile);
|
||||
|
||||
const isLoading = externalLoading || (fileFormat === "dicom" ? isDicomLoading : isNiftiLoading);
|
||||
|
||||
// Render appropriate component based on file format
|
||||
if (fileFormat === "nifti") {
|
||||
return (
|
||||
<>
|
||||
<NiftiQuadViewRenderer
|
||||
niftiData={niftiData}
|
||||
windowWidth={windowWidth}
|
||||
windowLevel={windowLevel}
|
||||
opacity={opacity}
|
||||
isLoading={isLoading}
|
||||
onRotate3D={onRotate3D}
|
||||
active2DView={active2DView}
|
||||
currentOrientation={currentOrientation}
|
||||
segmentationEnabled={segmentationEnabled}
|
||||
boundingBox={boundingBox}
|
||||
onBoundingBoxChange={onBoundingBoxChange}
|
||||
currentSliceIndex={currentSliceIndex}
|
||||
onSliceIndexChange={onSliceIndexChange}
|
||||
segmentationMask={segmentationMask}
|
||||
organMasks={organMasks}
|
||||
annotationTool={annotationTool}
|
||||
annotations={annotations}
|
||||
onAnnotationsChange={onAnnotationsChange}
|
||||
/>
|
||||
{niftiError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-30 bg-background/90 backdrop-blur-sm">
|
||||
<div className="text-center p-8 bg-destructive/10 border border-destructive rounded-lg">
|
||||
<p className="text-destructive font-semibold">Error loading NIfTI file</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">{niftiError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileFormat === "dicom") {
|
||||
return (
|
||||
<QuadViewRenderer
|
||||
dicomData={dicomData}
|
||||
windowWidth={windowWidth}
|
||||
windowLevel={windowLevel}
|
||||
opacity={opacity}
|
||||
isLoading={isLoading}
|
||||
onRotate3D={onRotate3D}
|
||||
active2DView={active2DView}
|
||||
currentOrientation={currentOrientation} // Pass it
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No files or unknown format
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||||
<div className="text-center p-8">
|
||||
<div className="text-6xl mb-4 opacity-30">🩻</div>
|
||||
<p className="text-muted-foreground">Upload DICOM or NIfTI files to view</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Supported formats: .dcm, .dicom, .nii, .nii.gz
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { RotateCw, RotateCcw, Navigation2 } from "lucide-react";
|
||||
|
||||
export type ViewOrientation =
|
||||
| "axial"
|
||||
| "coronal"
|
||||
| "sagittal"
|
||||
| "rotate-cw"
|
||||
| "rotate-ccw"
|
||||
| "anterior"
|
||||
| "posterior"
|
||||
| "left-lateral"
|
||||
| "right-lateral"
|
||||
| "superior"
|
||||
| "inferior";
|
||||
|
||||
export type Active2DView = "axial" | "coronal" | "sagittal" | null;
|
||||
|
||||
interface ViewRotationControlsProps {
|
||||
onRotate: (orientation: ViewOrientation) => void;
|
||||
onSelectView?: (view: Active2DView) => void;
|
||||
currentOrientation?: ViewOrientation;
|
||||
activeView?: Active2DView;
|
||||
}
|
||||
|
||||
const twoDViews: {
|
||||
id: Active2DView;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ id: "axial", label: "Axial", description: "Head to toe view" },
|
||||
{ id: "coronal", label: "Coronal", description: "Front to back view" },
|
||||
{ id: "sagittal", label: "Sagittal", description: "Left to right view" },
|
||||
];
|
||||
|
||||
export const ViewRotationControls = ({
|
||||
onRotate,
|
||||
onSelectView,
|
||||
currentOrientation,
|
||||
activeView,
|
||||
}: ViewRotationControlsProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 2D View Selection Dropdown */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Navigation2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{activeView
|
||||
? twoDViews.find(v => v.id === activeView)?.label
|
||||
: "Select View"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Select 2D View to Rotate</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>2D Views</DropdownMenuLabel>
|
||||
{twoDViews.map((view) => (
|
||||
<DropdownMenuItem
|
||||
key={view.id}
|
||||
onClick={() => {
|
||||
onSelectView?.(view.id);
|
||||
onRotate(view.id as ViewOrientation);
|
||||
}}
|
||||
className={activeView === view.id ? "bg-accent" : ""}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{view.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{view.description}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Clockwise Rotation Buttons */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRotate("rotate-ccw")}
|
||||
disabled={!activeView}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">CCW</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rotate Counter-Clockwise</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRotate("rotate-cw")}
|
||||
disabled={!activeView}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">CW</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rotate Clockwise</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Heavy viewer entry — pulls in @kitware/vtk.js + nifti-reader-js.
|
||||
*
|
||||
* Exposed as the `@ump/shared/viewer` subpath (NOT the main @ump/shared barrel)
|
||||
* so VTK is code-split into an async chunk and never bloats a page's initial
|
||||
* bundle. Import it through `React.lazy(() => import('@ump/shared/viewer'))`.
|
||||
*/
|
||||
export { UnifiedQuadViewRenderer } from './UnifiedQuadViewRenderer';
|
||||
export type { UnifiedQuadViewRendererProps, FileFormat } from './UnifiedQuadViewRenderer';
|
||||
export type { Annotation, AnnotationTool, AnnotationPoint } from './types';
|
||||
// Organ-mask overlays: a non-hook NIfTI->vtkImageData loader + the OrganMaskData contract,
|
||||
// so a host can build `organMasks` for the renderer outside React state.
|
||||
export { loadNiftiImageData, parseNiftiBuffer, labelValues, extractBinaryLabel } from './niftiLoader';
|
||||
export type { OrganMaskData, OrganName } from './types';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { distinctLabelValues, binaryLabelMask, isMultiLabel } from './labelMask';
|
||||
|
||||
describe('distinctLabelValues', () => {
|
||||
it('returns sorted distinct non-zero integers, ignoring 0', () => {
|
||||
expect(distinctLabelValues([0, 5, 1, 5, 0, 3, 1])).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it('ignores zero-only / empty volumes', () => {
|
||||
expect(distinctLabelValues([0, 0, 0])).toEqual([]);
|
||||
expect(distinctLabelValues([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips non-integer (continuous) values — those are images, not labels', () => {
|
||||
expect(distinctLabelValues([0, 1.5, 2, 2.0, 3.7])).toEqual([2]);
|
||||
});
|
||||
|
||||
it('works on a typed array (the real scalar buffer type)', () => {
|
||||
expect(distinctLabelValues(new Uint8Array([0, 2, 2, 117, 1]))).toEqual([1, 2, 117]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('binaryLabelMask', () => {
|
||||
it('marks 1 only where the scalar equals the value', () => {
|
||||
expect(Array.from(binaryLabelMask([0, 5, 1, 5, 3], 5))).toEqual([0, 1, 0, 1, 0]);
|
||||
});
|
||||
|
||||
it('returns an all-zero mask for an absent value', () => {
|
||||
expect(Array.from(binaryLabelMask([1, 2, 3], 9))).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('preserves length and is Uint8', () => {
|
||||
const m = binaryLabelMask([1, 2, 1], 1);
|
||||
expect(m).toBeInstanceOf(Uint8Array);
|
||||
expect(m.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMultiLabel', () => {
|
||||
it('is true with ≥2 distinct organ labels, false otherwise', () => {
|
||||
expect(isMultiLabel([0, 1, 2])).toBe(true);
|
||||
expect(isMultiLabel([0, 1, 1, 0])).toBe(false);
|
||||
expect(isMultiLabel([0, 0])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Multi-label segmentation helpers (pure — no VTK, no DOM, unit-testable).
|
||||
*
|
||||
* A single labelsTr/<case>.nii.gz can encode many organs as integer voxel values
|
||||
* (e.g. TotalSegmentator: 1..117). These split such a volume's scalar array into the
|
||||
* distinct label values present, and into a per-value binary (0/1) mask the renderer
|
||||
* can extract as one coloured iso-surface. The VTK wrappers live in `niftiLoader`.
|
||||
*/
|
||||
|
||||
/** Distinct non-zero integer label values present in a segmentation's scalar array, ascending. */
|
||||
export function distinctLabelValues(scalars: ArrayLike<number>): number[] {
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < scalars.length; i++) {
|
||||
const v = scalars[i];
|
||||
if (v > 0 && Number.isInteger(v)) seen.add(v);
|
||||
}
|
||||
return [...seen].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** A binary (0/1) Uint8Array mask: 1 where the scalar equals `value`, else 0. */
|
||||
export function binaryLabelMask(scalars: ArrayLike<number>, value: number): Uint8Array {
|
||||
const out = new Uint8Array(scalars.length);
|
||||
for (let i = 0; i < scalars.length; i++) {
|
||||
if (scalars[i] === value) out[i] = 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when a segmentation holds more than one distinct organ label (so it is worth splitting). */
|
||||
export function isMultiLabel(scalars: ArrayLike<number>): boolean {
|
||||
return distinctLabelValues(scalars).length > 1;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
orientationFromAffine,
|
||||
isCanonicalOrientation,
|
||||
applyCanonicalOrientation,
|
||||
} from "./niftiLoader";
|
||||
|
||||
// Real affines measured from the two dev-stack files (via nibabel) — see the KiTS
|
||||
// orientation investigation. ct.nii.gz (TotalSegmentator) is RAS; KIT23_00002_0000.nii.gz
|
||||
// (RibFrac dataset) is ('I','P','L').
|
||||
const RAS_AFFINE = [
|
||||
[1.5, 0, 0, 0],
|
||||
[0, 1.5, 0, 0],
|
||||
[0, 0, 1.5, 0],
|
||||
[0, 0, 0, 1],
|
||||
];
|
||||
const KIT23_IPL_AFFINE = [
|
||||
[0, 0, -0.9395, 0],
|
||||
[0, -0.9395, 0, 0],
|
||||
[-1.0, 0, 0, 0],
|
||||
[0, 0, 0, 1],
|
||||
];
|
||||
|
||||
describe("orientationFromAffine", () => {
|
||||
it("treats a clean RAS (diagonal, positive) affine as canonical — no reorder/flip", () => {
|
||||
const o = orientationFromAffine(RAS_AFFINE)!;
|
||||
expect(o.perm).toEqual([0, 1, 2]);
|
||||
expect(o.flips).toEqual([false, false, false]);
|
||||
expect(isCanonicalOrientation(o)).toBe(true);
|
||||
});
|
||||
|
||||
it("maps the KiTS ('I','P','L') affine to a full axis reversal + flip-all", () => {
|
||||
const o = orientationFromAffine(KIT23_IPL_AFFINE)!;
|
||||
expect(o.perm).toEqual([2, 1, 0]);
|
||||
expect(o.flips).toEqual([true, true, true]);
|
||||
expect(isCanonicalOrientation(o)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a pure permutation (axes swapped, no flip)", () => {
|
||||
// array axis0→S, axis1→A, axis2→R, all positive
|
||||
const o = orientationFromAffine([
|
||||
[0, 0, 1, 0],
|
||||
[0, 1, 0, 0],
|
||||
[1, 0, 0, 0],
|
||||
[0, 0, 0, 1],
|
||||
])!;
|
||||
expect(o.perm).toEqual([2, 1, 0]);
|
||||
expect(o.flips).toEqual([false, false, false]);
|
||||
});
|
||||
|
||||
it("handles a pure flip (in-order axes, one reversed)", () => {
|
||||
const o = orientationFromAffine([
|
||||
[1, 0, 0, 0],
|
||||
[0, -1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1],
|
||||
])!;
|
||||
expect(o.perm).toEqual([0, 1, 2]);
|
||||
expect(o.flips).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("returns null for a missing / too-short / singular affine (safe fallback)", () => {
|
||||
expect(orientationFromAffine(null)).toBeNull();
|
||||
expect(orientationFromAffine([])).toBeNull();
|
||||
expect(orientationFromAffine([[1, 0], [0, 1]])).toBeNull(); // < 3 rows
|
||||
// singular: a zero column means an array axis maps nowhere
|
||||
expect(
|
||||
orientationFromAffine([
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 1],
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCanonicalOrientation", () => {
|
||||
// A 2x3x4 volume (axis0 fastest-varying) filled with its own flat index, so a remapped
|
||||
// voxel's value IS its original flat index — easy to assert the mapping.
|
||||
const dims: [number, number, number] = [2, 3, 4];
|
||||
const spacing: [number, number, number] = [1, 2, 3];
|
||||
const data = new Float32Array(2 * 3 * 4);
|
||||
for (let i = 0; i < data.length; i++) data[i] = i;
|
||||
|
||||
it("reverses + flips a KiTS-style volume to canonical, swapping dims and spacing", () => {
|
||||
const o = { perm: [2, 1, 0] as [number, number, number], flips: [true, true, true] as [boolean, boolean, boolean] };
|
||||
const r = applyCanonicalOrientation(data, dims, spacing, o);
|
||||
expect(r.dims).toEqual([4, 3, 2]); // dims[perm]
|
||||
expect(r.spacing).toEqual([3, 2, 1]); // spacing[perm]
|
||||
expect(r.data.length).toBe(24);
|
||||
// Full reversal: canonical (0,0,0) ← source far corner; canonical last ← source (0,0,0).
|
||||
expect(r.data[0]).toBe(23);
|
||||
expect(r.data[23]).toBe(0);
|
||||
// Stepping the fastest output axis maps back along the (flipped) source axis 2.
|
||||
expect(r.data[1]).toBe(17);
|
||||
});
|
||||
|
||||
it("is a value-preserving no-op for the canonical orientation", () => {
|
||||
const o = { perm: [0, 1, 2] as [number, number, number], flips: [false, false, false] as [boolean, boolean, boolean] };
|
||||
const r = applyCanonicalOrientation(data, dims, spacing, o);
|
||||
expect(r.dims).toEqual([2, 3, 4]);
|
||||
expect(r.spacing).toEqual([1, 2, 3]);
|
||||
expect(Array.from(r.data)).toEqual(Array.from(data));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import * as nifti from "nifti-reader-js";
|
||||
import vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData";
|
||||
import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray";
|
||||
import type { NiftiData } from "./types";
|
||||
import { binaryLabelMask, distinctLabelValues } from "./labelMask";
|
||||
|
||||
/**
|
||||
* Anatomical reorientation to closest-canonical RAS+ — a JS port of nibabel's
|
||||
* `as_closest_canonical`. The quad-view renderer assumes the voxel array is already in
|
||||
* canonical order (axis0 = L→R, axis1 = P→A, axis2 = I→S); TotalSegmentator output is
|
||||
* resampled to RAS so it renders correctly, but raw clinical data keeps its acquisition
|
||||
* orientation (e.g. KiTS/RibFrac files are `('I','P','L')`) and would otherwise render with
|
||||
* permuted/flipped axial/coronal/sagittal planes. We read the NIfTI affine and reorder + flip
|
||||
* the array to RAS+ so EVERY file matches the renderer's assumption.
|
||||
*/
|
||||
export type CanonicalOrientation = {
|
||||
/** perm[k] = the source array axis that becomes canonical axis k (0=R/X, 1=A/Y, 2=S/Z). */
|
||||
perm: [number, number, number];
|
||||
/** flips[k] = whether canonical axis k is reversed so +index points toward R / A / S. */
|
||||
flips: [boolean, boolean, boolean];
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the RAS+ reorientation from a 4x4 NIfTI affine (`affine[worldRow][voxelCol]`, the
|
||||
* convention nifti-reader-js builds from sform/qform). Greedy axis assignment, mirroring
|
||||
* nibabel's `io_orientation`. Returns null when the affine is missing/singular so the caller
|
||||
* safely falls back to the raw array order.
|
||||
*/
|
||||
export function orientationFromAffine(
|
||||
affine: number[][] | undefined | null,
|
||||
): CanonicalOrientation | null {
|
||||
if (!affine || affine.length < 3) return null;
|
||||
const m: number[][] = [];
|
||||
for (let w = 0; w < 3; w++) {
|
||||
const row = affine[w];
|
||||
if (!row || row.length < 3) return null;
|
||||
m.push([row[0], row[1], row[2]]);
|
||||
}
|
||||
const worldForInput = [-1, -1, -1];
|
||||
const signForInput = [1, 1, 1];
|
||||
const usedInput = [false, false, false];
|
||||
const usedWorld = [false, false, false];
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
let best = -1;
|
||||
let bi = -1;
|
||||
let bw = -1;
|
||||
for (let w = 0; w < 3; w++) {
|
||||
if (usedWorld[w]) continue;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (usedInput[i]) continue;
|
||||
const v = Math.abs(m[w][i]);
|
||||
if (v > best) {
|
||||
best = v;
|
||||
bw = w;
|
||||
bi = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bi < 0 || best === 0) return null; // singular / degenerate orientation
|
||||
worldForInput[bi] = bw;
|
||||
signForInput[bi] = m[bw][bi] < 0 ? -1 : 1;
|
||||
usedInput[bi] = true;
|
||||
usedWorld[bw] = true;
|
||||
}
|
||||
const perm: [number, number, number] = [0, 0, 0];
|
||||
const flips: [boolean, boolean, boolean] = [false, false, false];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = worldForInput[i];
|
||||
perm[w] = i;
|
||||
flips[w] = signForInput[i] < 0;
|
||||
}
|
||||
return { perm, flips };
|
||||
}
|
||||
|
||||
/** True when the orientation is already RAS+ (no reorder, no flip) — the canonical fast path. */
|
||||
export function isCanonicalOrientation(o: CanonicalOrientation): boolean {
|
||||
return (
|
||||
o.perm[0] === 0 &&
|
||||
o.perm[1] === 1 &&
|
||||
o.perm[2] === 2 &&
|
||||
!o.flips[0] &&
|
||||
!o.flips[1] &&
|
||||
!o.flips[2]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder + flip a flat voxel array (axis0 fastest-varying) into RAS+ canonical order,
|
||||
* returning the canonical data + dims + spacing. O(N) with incremental strided indexing
|
||||
* (one read/write per voxel); only invoked for non-canonical files.
|
||||
*/
|
||||
export function applyCanonicalOrientation(
|
||||
data: Float32Array,
|
||||
dims: [number, number, number],
|
||||
spacing: [number, number, number],
|
||||
o: CanonicalOrientation,
|
||||
): { data: Float32Array; dims: [number, number, number]; spacing: [number, number, number] } {
|
||||
const inDims = dims;
|
||||
const inStride = [1, inDims[0], inDims[0] * inDims[1]];
|
||||
const outDims: [number, number, number] = [dims[o.perm[0]], dims[o.perm[1]], dims[o.perm[2]]];
|
||||
const outSpacing: [number, number, number] = [
|
||||
spacing[o.perm[0]],
|
||||
spacing[o.perm[1]],
|
||||
spacing[o.perm[2]],
|
||||
];
|
||||
const [m0, m1, m2] = outDims;
|
||||
// Per output axis k: the input-flat step for o_k+1, and the base offset (o_k=0).
|
||||
const step = [0, 0, 0];
|
||||
const start = [0, 0, 0];
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const a = o.perm[k];
|
||||
step[k] = (o.flips[k] ? -1 : 1) * inStride[a];
|
||||
start[k] = o.flips[k] ? (inDims[a] - 1) * inStride[a] : 0;
|
||||
}
|
||||
const base = start[0] + start[1] + start[2];
|
||||
const out = new Float32Array(data.length);
|
||||
let outFlat = 0;
|
||||
let b2 = base;
|
||||
for (let o2 = 0; o2 < m2; o2++) {
|
||||
let b1 = b2;
|
||||
for (let o1 = 0; o1 < m1; o1++) {
|
||||
let inIdx = b1;
|
||||
for (let o0 = 0; o0 < m0; o0++) {
|
||||
out[outFlat++] = data[inIdx];
|
||||
inIdx += step[0];
|
||||
}
|
||||
b1 += step[1];
|
||||
}
|
||||
b2 += step[2];
|
||||
}
|
||||
return { data: out, dims: outDims, spacing: outSpacing };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a (possibly gzipped) NIfTI ArrayBuffer into a VTK ImageData + header/geometry.
|
||||
*
|
||||
* Pure + non-hook so it can be shared by BOTH the `useNiftiData` hook (the main image)
|
||||
* and the organ-mask overlay loader (which loads N extra volumes outside React state).
|
||||
* Throws on a non-NIfTI buffer. Mirrors the original in-hook conversion exactly
|
||||
* (datatype switch + scl_slope/scl_inter scaling + dims/spacing).
|
||||
*/
|
||||
export function parseNiftiBuffer(input: ArrayBuffer): NiftiData {
|
||||
let arrayBuffer: ArrayBuffer = input;
|
||||
if (nifti.isCompressed(arrayBuffer)) {
|
||||
arrayBuffer = nifti.decompress(arrayBuffer) as ArrayBuffer;
|
||||
}
|
||||
if (!nifti.isNIFTI(arrayBuffer)) {
|
||||
throw new Error("Not a valid NIfTI file");
|
||||
}
|
||||
|
||||
const header = nifti.readHeader(arrayBuffer);
|
||||
if (!header) {
|
||||
throw new Error("Could not read NIfTI header");
|
||||
}
|
||||
const imageBuffer = nifti.readImage(header, arrayBuffer);
|
||||
if (!imageBuffer) {
|
||||
throw new Error("Could not read NIfTI image data");
|
||||
}
|
||||
|
||||
const dims = header.dims;
|
||||
const nx = dims[1];
|
||||
const ny = dims[2];
|
||||
const nz = dims[3];
|
||||
|
||||
const pixDims = header.pixDims;
|
||||
const sx = Math.abs(pixDims[1]) || 1;
|
||||
const sy = Math.abs(pixDims[2]) || 1;
|
||||
const sz = Math.abs(pixDims[3]) || 1;
|
||||
|
||||
let typedData: Float32Array | Int16Array | Uint16Array | Uint8Array | Int8Array;
|
||||
switch (header.datatypeCode) {
|
||||
case nifti.NIFTI1.TYPE_UINT8:
|
||||
typedData = new Uint8Array(imageBuffer);
|
||||
break;
|
||||
case nifti.NIFTI1.TYPE_INT8:
|
||||
typedData = new Int8Array(imageBuffer);
|
||||
break;
|
||||
case nifti.NIFTI1.TYPE_UINT16:
|
||||
typedData = new Uint16Array(imageBuffer);
|
||||
break;
|
||||
case nifti.NIFTI1.TYPE_INT16:
|
||||
typedData = new Int16Array(imageBuffer);
|
||||
break;
|
||||
case nifti.NIFTI1.TYPE_FLOAT32:
|
||||
typedData = new Float32Array(imageBuffer);
|
||||
break;
|
||||
case nifti.NIFTI1.TYPE_FLOAT64: {
|
||||
const float64 = new Float64Array(imageBuffer);
|
||||
typedData = new Float32Array(float64);
|
||||
break;
|
||||
}
|
||||
case nifti.NIFTI1.TYPE_INT32: {
|
||||
const int32 = new Int32Array(imageBuffer);
|
||||
typedData = new Float32Array(int32);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
typedData = new Float32Array(imageBuffer);
|
||||
}
|
||||
|
||||
const slope = header.scl_slope || 1;
|
||||
const intercept = header.scl_inter || 0;
|
||||
let scaledData: Float32Array;
|
||||
if (slope !== 1 || intercept !== 0) {
|
||||
scaledData = new Float32Array(typedData.length);
|
||||
for (let i = 0; i < typedData.length; i++) {
|
||||
scaledData[i] = typedData[i] * slope + intercept;
|
||||
}
|
||||
} else {
|
||||
scaledData = typedData instanceof Float32Array ? typedData : new Float32Array(typedData);
|
||||
}
|
||||
|
||||
// Reorient to closest-canonical RAS+ so a file in any acquisition orientation (e.g.
|
||||
// KiTS/RibFrac = ('I','P','L')) renders with the axial/coronal/sagittal mapping the
|
||||
// renderer assumes. Canonical files (TotalSegmentator = RAS) hit the no-op fast path.
|
||||
let outData: Float32Array = scaledData;
|
||||
let outDims: [number, number, number] = [nx, ny, nz];
|
||||
let outSpacing: [number, number, number] = [sx, sy, sz];
|
||||
const orientation = orientationFromAffine(header.affine);
|
||||
if (orientation && !isCanonicalOrientation(orientation)) {
|
||||
const reoriented = applyCanonicalOrientation(scaledData, [nx, ny, nz], [sx, sy, sz], orientation);
|
||||
outData = reoriented.data;
|
||||
outDims = reoriented.dims;
|
||||
outSpacing = reoriented.spacing;
|
||||
}
|
||||
|
||||
const imageData = vtkImageData.newInstance();
|
||||
imageData.setDimensions(outDims);
|
||||
imageData.setSpacing(outSpacing);
|
||||
|
||||
const dataArray = vtkDataArray.newInstance({
|
||||
name: "Scalars",
|
||||
numberOfComponents: 1,
|
||||
values: outData,
|
||||
});
|
||||
imageData.getPointData().setScalars(dataArray);
|
||||
|
||||
return {
|
||||
header: {
|
||||
dims,
|
||||
pixDims,
|
||||
datatype: header.datatypeCode,
|
||||
littleEndian: header.littleEndian,
|
||||
voxOffset: header.vox_offset,
|
||||
affine: header.affine || [],
|
||||
description: header.description || "",
|
||||
},
|
||||
imageData,
|
||||
rawData: arrayBuffer,
|
||||
dimensions: outDims,
|
||||
spacing: outSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a NIfTI File into a VTK ImageData — for organ-mask overlays (`OrganMaskData.imageData`).
|
||||
* The mask must share the parent image's voxel grid (dims + spacing) to overlay correctly;
|
||||
* no resampling/affine alignment is applied here.
|
||||
*/
|
||||
export async function loadNiftiImageData(file: File): Promise<NiftiData["imageData"]> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
return parseNiftiBuffer(buffer).imageData;
|
||||
}
|
||||
|
||||
/** Distinct non-zero integer label values present in a loaded segmentation volume. */
|
||||
export function labelValues(imageData: NiftiData["imageData"]): number[] {
|
||||
return distinctLabelValues(imageData.getPointData().getScalars().getData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ONE organ from a multi-label volume as a binary (0/1) vtkImageData that shares the
|
||||
* source grid (dims / spacing / origin) — so the renderer's contour-at-0.5 + value-1 slice colour
|
||||
* pick out exactly that organ. Lets a single labelsTr/<case>.nii.gz drive N per-organ overlays.
|
||||
*/
|
||||
export function extractBinaryLabel(imageData: NiftiData["imageData"], value: number): NiftiData["imageData"] {
|
||||
const scalars = imageData.getPointData().getScalars().getData();
|
||||
const out = vtkImageData.newInstance();
|
||||
out.setDimensions(imageData.getDimensions());
|
||||
out.setSpacing(imageData.getSpacing());
|
||||
out.setOrigin(imageData.getOrigin());
|
||||
out.getPointData().setScalars(
|
||||
vtkDataArray.newInstance({ name: "Scalars", numberOfComponents: 1, values: binaryLabelMask(scalars, value) }),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface NiftiHeader {
|
||||
dims: number[];
|
||||
pixDims: number[];
|
||||
datatype: number;
|
||||
littleEndian: boolean;
|
||||
voxOffset: number;
|
||||
affine: number[][];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NiftiData {
|
||||
header: NiftiHeader;
|
||||
imageData: any; // vtkImageData
|
||||
rawData: ArrayBuffer;
|
||||
dimensions: [number, number, number];
|
||||
spacing: [number, number, number];
|
||||
}
|
||||
|
||||
export interface NiftiViewerProps {
|
||||
file: File | null;
|
||||
windowWidth?: number;
|
||||
windowLevel?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types extracted from the segmentation / organ-mask modules that we did NOT
|
||||
// port (SegmentationTool, OrganSelector, useOrganMasks). The NIfTI renderer
|
||||
// references these only as `import type`, and the viewer is wired with those
|
||||
// features disabled (segmentation needs an /api/medsam2/predict backend; organ
|
||||
// overlays need a mask source — neither exists in ImageHub yet). Kept here as
|
||||
// standalone aliases so the render path typechecks without the dropped modules.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
sliceIndex: number;
|
||||
viewType: "axial" | "coronal" | "sagittal";
|
||||
}
|
||||
|
||||
export interface SegmentationMask {
|
||||
viewType: "axial" | "coronal" | "sagittal";
|
||||
sliceIndex: number;
|
||||
// Normalized coordinates (0-1) for the mask polygon points
|
||||
polygonPoints?: Array<{ x: number; y: number }>;
|
||||
// Or a base64-encoded mask image
|
||||
maskImageData?: string;
|
||||
// Bounding box of the mask (normalized 0-1)
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
// Confidence score
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
// Originally a union of organ names from OrganSelector; we only consume this
|
||||
// type (never construct organ masks), so a string alias is sufficient.
|
||||
export type OrganName = string;
|
||||
|
||||
export interface OrganMaskData {
|
||||
/** Stable unique id (e.g. the mask file id). The renderer keys overlays by this so two
|
||||
* organs that share a display label don't collapse into one. Falls back to organName. */
|
||||
id?: string;
|
||||
organName: OrganName;
|
||||
imageData: any; // vtkImageData
|
||||
color: [number, number, number];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client-side annotation model (toolbar tools: bbox / points / pen / brush /
|
||||
// polygon). Geometry is stored in NORMALIZED [0..1] coordinates within a single
|
||||
// 2D pane, tagged with that pane's view + slice index so an annotation only
|
||||
// shows on the slice it was drawn on. `bbox` uses [topLeft, bottomRight];
|
||||
// the freehand/multi-vertex tools store an ordered list of vertices.
|
||||
// ---------------------------------------------------------------------------
|
||||
export type AnnotationTool = 'none' | 'bbox' | 'points' | 'pen' | 'brush' | 'polygon';
|
||||
|
||||
export interface AnnotationPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
view: 'axial' | 'coronal' | 'sagittal';
|
||||
sliceIndex: number;
|
||||
tool: Exclude<AnnotationTool, 'none'>;
|
||||
points: AnnotationPoint[];
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
label?: string;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData";
|
||||
import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray";
|
||||
|
||||
interface ParsedSlice {
|
||||
data: Uint8Array | Int16Array;
|
||||
width: number;
|
||||
height: number;
|
||||
bitsAllocated: 8 | 16;
|
||||
}
|
||||
|
||||
interface DicomData {
|
||||
imageData: any;
|
||||
slices: ParsedSlice[];
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
// Simple DICOM parser for raw pixel data
|
||||
const parseDicomBuffer = (buffer: Uint8Array): ParsedSlice | null => {
|
||||
try {
|
||||
const hasDicmPrefix =
|
||||
buffer.length > 132 &&
|
||||
buffer[128] === 68 &&
|
||||
buffer[129] === 73 &&
|
||||
buffer[130] === 67 &&
|
||||
buffer[131] === 77;
|
||||
|
||||
let offset = hasDicmPrefix ? 132 : 0;
|
||||
let width = 512;
|
||||
let height = 512;
|
||||
let bitsAllocated: 8 | 16 = 16;
|
||||
let pixelDataOffset = -1;
|
||||
let pixelDataLength = 0;
|
||||
|
||||
while (offset < buffer.length - 8) {
|
||||
const group = buffer[offset] | (buffer[offset + 1] << 8);
|
||||
const element = buffer[offset + 2] | (buffer[offset + 3] << 8);
|
||||
offset += 4;
|
||||
|
||||
let length: number;
|
||||
const vr = String.fromCharCode(buffer[offset], buffer[offset + 1]);
|
||||
|
||||
if (["OB", "OW", "OF", "SQ", "UC", "UN", "UR", "UT"].includes(vr)) {
|
||||
offset += 4;
|
||||
length =
|
||||
buffer[offset] |
|
||||
(buffer[offset + 1] << 8) |
|
||||
(buffer[offset + 2] << 16) |
|
||||
(buffer[offset + 3] << 24);
|
||||
offset += 4;
|
||||
} else if (vr.match(/[A-Z]{2}/)) {
|
||||
offset += 2;
|
||||
length = buffer[offset] | (buffer[offset + 1] << 8);
|
||||
offset += 2;
|
||||
} else {
|
||||
length =
|
||||
buffer[offset - 4] |
|
||||
(buffer[offset - 3] << 8) |
|
||||
(buffer[offset - 2] << 16) |
|
||||
(buffer[offset - 1] << 24);
|
||||
}
|
||||
|
||||
if (group === 0x0028 && element === 0x0010) {
|
||||
height = buffer[offset] | (buffer[offset + 1] << 8);
|
||||
} else if (group === 0x0028 && element === 0x0011) {
|
||||
width = buffer[offset] | (buffer[offset + 1] << 8);
|
||||
} else if (group === 0x0028 && element === 0x0100) {
|
||||
const v = buffer[offset] | (buffer[offset + 1] << 8);
|
||||
bitsAllocated = v === 8 ? 8 : 16;
|
||||
} else if (group === 0x7fe0 && element === 0x0010) {
|
||||
pixelDataOffset = offset;
|
||||
pixelDataLength = length;
|
||||
break;
|
||||
}
|
||||
|
||||
if (length === 0xffffffff) break;
|
||||
offset += length;
|
||||
}
|
||||
|
||||
if (pixelDataOffset === -1) {
|
||||
const expectedSize = width * height * (bitsAllocated / 8);
|
||||
if (buffer.length >= expectedSize) {
|
||||
pixelDataOffset = buffer.length - expectedSize;
|
||||
pixelDataLength = expectedSize;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (bitsAllocated === 16) {
|
||||
// Interpret as SIGNED Int16 (typical CT storage), keep raw values for window/level
|
||||
const pixelBuffer = buffer.slice(pixelDataOffset, pixelDataOffset + pixelDataLength);
|
||||
const out = new Int16Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const low = pixelBuffer[i * 2] ?? 0;
|
||||
const high = pixelBuffer[i * 2 + 1] ?? 0;
|
||||
let value = low | (high << 8);
|
||||
if (value & 0x8000) value = value - 0x10000; // signed
|
||||
out[i] = value;
|
||||
}
|
||||
return { data: out, width, height, bitsAllocated: 16 };
|
||||
}
|
||||
|
||||
// 8-bit fallback
|
||||
const pixelBuffer = buffer.slice(
|
||||
pixelDataOffset,
|
||||
pixelDataOffset + Math.min(pixelDataLength, width * height)
|
||||
);
|
||||
const out = new Uint8Array(width * height);
|
||||
out.set(pixelBuffer.subarray(0, out.length));
|
||||
return { data: out, width, height, bitsAllocated: 8 };
|
||||
} catch (error) {
|
||||
console.error("Error parsing DICOM:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const useDicomData = (dicomFiles: File[]) => {
|
||||
const [dicomData, setDicomData] = useState<DicomData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastFilesRef = useRef<string>("");
|
||||
|
||||
const loadDicomFiles = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
setDicomData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a unique key from file names and sizes to detect changes
|
||||
const filesKey = files
|
||||
.map((f) => `${f.name}-${f.size}-${f.lastModified}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
// Skip if we already loaded these files
|
||||
if (filesKey === lastFilesRef.current && dicomData) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastFilesRef.current = filesKey;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const sortedFiles = [...files].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { numeric: true })
|
||||
);
|
||||
|
||||
const slices: ParsedSlice[] = [];
|
||||
|
||||
for (const file of sortedFiles) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const parsed = parseDicomBuffer(new Uint8Array(arrayBuffer));
|
||||
if (parsed) {
|
||||
slices.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (slices.length === 0) {
|
||||
setError("No valid DICOM data found");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const width = slices[0].width;
|
||||
const height = slices[0].height;
|
||||
const depth = slices.length;
|
||||
|
||||
const is16Bit = slices[0].bitsAllocated === 16;
|
||||
const voxelCount = width * height * depth;
|
||||
|
||||
const volumeData = is16Bit ? new Int16Array(voxelCount) : new Uint8Array(voxelCount);
|
||||
|
||||
for (let z = 0; z < depth; z++) {
|
||||
const slice = slices[z];
|
||||
const sliceOffset = z * width * height;
|
||||
if (is16Bit) {
|
||||
(volumeData as Int16Array).set(slice.data as Int16Array, sliceOffset);
|
||||
} else {
|
||||
(volumeData as Uint8Array).set(slice.data as Uint8Array, sliceOffset);
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = vtkImageData.newInstance();
|
||||
imageData.setDimensions(width, height, depth);
|
||||
imageData.setSpacing([1.0, 1.0, 1.0]);
|
||||
|
||||
const scalars = vtkDataArray.newInstance({
|
||||
name: "Scalars",
|
||||
numberOfComponents: 1,
|
||||
values: volumeData as any,
|
||||
});
|
||||
|
||||
imageData.getPointData().setScalars(scalars);
|
||||
|
||||
setDicomData({
|
||||
imageData,
|
||||
slices,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading DICOM files:", err);
|
||||
setError("Error loading DICOM files");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dicomData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDicomFiles(dicomFiles);
|
||||
}, [dicomFiles, loadDicomFiles]);
|
||||
|
||||
return { dicomData, isLoading, error };
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { NiftiData } from "./types";
|
||||
import { parseNiftiBuffer } from "./niftiLoader";
|
||||
|
||||
export function useNiftiData(file: File | null) {
|
||||
const [niftiData, setNiftiData] = useState<NiftiData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastFileRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setNiftiData(null);
|
||||
lastFileRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a unique identifier for the file to detect changes
|
||||
// Use name, size, and lastModified to ensure we detect different files
|
||||
const fileKey = `${file.name}-${file.size}-${file.lastModified}`;
|
||||
|
||||
// Skip if we already loaded this exact file
|
||||
if (fileKey === lastFileRef.current && niftiData) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastFileRef.current = fileKey;
|
||||
|
||||
const loadNifti = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
setNiftiData(parseNiftiBuffer(arrayBuffer));
|
||||
} catch (err) {
|
||||
console.error("Error loading NIfTI:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load NIfTI file");
|
||||
setNiftiData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNifti();
|
||||
}, [file, niftiData]);
|
||||
|
||||
return { niftiData, isLoading, error };
|
||||
}
|
||||
Reference in New Issue
Block a user