sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
@@ -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>
);
};
+14
View File
@@ -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);
});
});
+32
View File
@@ -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));
});
});
+285
View File
@@ -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;
}
+92
View File
@@ -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 };
}