2026-05-07-17-28-34 实现DICOM与模型三维融合视角

This commit is contained in:
2026-05-07 17:36:34 +08:00
parent 97f5c78907
commit cbac61eabc
8 changed files with 930 additions and 174 deletions

View File

@@ -618,6 +618,53 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
};
}
function createDicomFusionVolume(files: string[], start: number, end: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(files, mode);
const total = volume.frames.length;
const safeStart = Math.max(0, Math.min(total - 1, Number.isFinite(start) ? start : 0));
const safeEnd = Math.max(safeStart, Math.min(total - 1, Number.isFinite(end) ? end : safeStart + 49));
const maxFrames = 64;
const rangeLength = safeEnd - safeStart + 1;
const step = Math.max(1, Math.ceil(rangeLength / maxFrames));
const indices: number[] = [];
for (let index = safeStart; index <= safeEnd; index += step) {
indices.push(index);
}
if (indices[indices.length - 1] !== safeEnd) {
indices.push(safeEnd);
}
const maxTextureDimension = 256;
const textureScale = Math.min(1, maxTextureDimension / Math.max(volume.width, volume.height));
const targetWidth = Math.max(1, Math.round(volume.width * textureScale));
const targetHeight = Math.max(1, Math.round(volume.height * textureScale));
const frames = indices.map((index) => (
resampleNearest(volume.frames[index], volume.width, volume.height, targetWidth, targetHeight).toString('base64')
));
return {
width: targetWidth,
height: targetHeight,
start: safeStart,
end: safeEnd,
total,
indices,
frames,
mode,
spacing: {
row: volume.rowSpacing,
column: volume.columnSpacing,
slice: volume.sliceSpacing,
},
physicalSize: {
width: volume.width * volume.columnSpacing,
height: volume.height * volume.rowSpacing,
depth: Math.max(1, rangeLength) * volume.sliceSpacing,
unit: 'mm',
},
};
}
function enhanceDicomEdges(pixels: Buffer, width: number, height: number) {
if (width < 3 || height < 3) {
return pixels;
@@ -1055,6 +1102,31 @@ async function startServer() {
}
});
app.get('/api/projects/:projectId/dicom-fusion-volume', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const files = getProjectDicomFiles(project);
if (!files.length) {
res.status(404).json({ message: '当前项目没有可融合的 DICOM 文件' });
return;
}
const requestedMode = String(req.query.mode ?? 'soft');
const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'soft';
const start = Number.parseInt(String(req.query.start ?? '0'), 10);
const end = Number.parseInt(String(req.query.end ?? '49'), 10);
try {
res.json(createDicomFusionVolume(files, start, end, mode));
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' });
}
});
app.get('/api/projects/:projectId/dicom-archive', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {

View File

@@ -1,64 +1,418 @@
import React, { useRef, useState, useEffect } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import {
Dices,
Settings2,
Maximize2,
Download,
Layers,
Move,
import {
Dices,
Settings2,
Maximize2,
Download,
Layers,
Rotate3d,
CheckCircle2,
AlertCircle,
FileJson,
Plus,
Play
Play,
} from 'lucide-react';
import { DicomPreview, MaskMapping, Project } from '../types';
import * as THREE from 'three';
import { DicomFusionVolume, MaskMapping, Project } from '../types';
import { api, downloadMask } from '../lib/api';
function FusionDicomCanvas({ preview }: { preview: DicomPreview }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
interface ModelPose {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
}
interface ModelPreviewPayload {
fileName: string;
triangleCount: number;
sampledTriangles: number;
vertices: number[];
bounds?: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
};
}
const defaultModelPose: ModelPose = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
};
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function createDicomTexture(frame: string, width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
const binary = atob(frame);
const imageData = context.createImageData(width, height);
for (let index = 0; index < binary.length; index += 1) {
const value = binary.charCodeAt(index);
const offset = index * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = value > 4 ? 235 : 0;
}
context.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
function FusionThreeView({
project,
volume,
modelPose,
onModelPoseChange,
}: {
project: Project;
volume: DicomFusionVolume | null;
modelPose: ModelPose;
onModelPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
const onModelPoseChangeRef = useRef(onModelPoseChange);
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
const [loadProgress, setLoadProgress] = useState(0);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
modelPoseRef.current = modelPose;
}, [modelPose]);
const binary = atob(preview.pixels);
const imageData = context.createImageData(preview.width, preview.height);
for (let i = 0; i < binary.length; i += 1) {
const value = binary.charCodeAt(i);
const offset = i * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = 255;
}
context.putImageData(imageData, 0, 0);
}, [preview]);
useEffect(() => {
onModelPoseChangeRef.current = onModelPoseChange;
}, [onModelPoseChange]);
useEffect(() => {
const container = containerRef.current;
if (!container || !volume) return;
container.innerHTML = '';
setStatus('正在构建三维融合场景...');
setLoadProgress(8);
let disposed = false;
let animationId = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#030712');
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000);
camera.position.set(0, -6.2, 4.6);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
keyLight.position.set(4, -5, 5);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
fillLight.position.set(-4, 3, 2);
scene.add(fillLight);
const fusionRoot = new THREE.Group();
const dicomGroup = new THREE.Group();
const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot);
fusionRoot.add(dicomGroup);
fusionRoot.add(modelPoseGroup);
scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
const baseExtent = 4.6;
const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
const planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight);
const box = new THREE.Mesh(
new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: 0.32, depthWrite: false }),
);
dicomGroup.add(box);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(box.geometry),
new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }),
);
dicomGroup.add(edges);
const textures: THREE.Texture[] = [];
volume.frames.forEach((frame, index) => {
const texture = createDicomTexture(frame, volume.width, volume.height);
if (!texture) return;
textures.push(texture);
const isLast = index === volume.frames.length - 1;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: isLast ? 0.82 : 0.12,
side: THREE.DoubleSide,
depthWrite: false,
});
const slicePlane = new THREE.Mesh(planeGeometry, material);
const z = volume.frames.length <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1);
slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z);
dicomGroup.add(slicePlane);
});
setLoadProgress(42);
const stlFiles = project.stlFiles ?? [];
let modelBaseScale = 1;
let loadedModels = 0;
let failedModels = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
Promise.allSettled(stlFiles.map((fileName, index) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=72000`)
.then((response) => {
if (!response.ok) throw new Error('模型预览加载失败');
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => {
if (disposed) return;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
color: moduleColors[index % moduleColors.length],
transparent: true,
opacity: 0.72,
roughness: 0.48,
metalness: 0.03,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
modelPivot.add(mesh);
if (payload.bounds) {
loadedBounds.push({
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
});
}
loadedModels += 1;
setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
})
))).then(() => {
if (disposed) return;
const modelBox = new THREE.Box3();
if (loadedBounds.length) {
loadedBounds.forEach((bounds) => {
modelBox.expandByPoint(bounds.min);
modelBox.expandByPoint(bounds.max);
});
} else {
modelBox.setFromObject(modelPivot);
}
const center = modelBox.getCenter(new THREE.Vector3());
const size = modelBox.getSize(new THREE.Vector3());
const maxModelSize = Math.max(size.x, size.y, size.z, 1);
modelPivot.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.translate(-center.x, -center.y, -center.z);
object.geometry.computeBoundingBox();
object.geometry.computeBoundingSphere();
object.geometry.computeVertexNormals();
}
});
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
modelPoseGroup.position.set(0, 0, dicomDepth * 0.08);
setLoadProgress(100);
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
});
const rootPose = {
rotateX: THREE.MathUtils.degToRad(58),
rotateY: 0,
rotateZ: THREE.MathUtils.degToRad(-18),
translateX: 0,
translateY: 0,
scale: 1,
};
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
pointerId: 0,
startX: 0,
startY: 0,
root: { ...rootPose },
};
const handlePointerDown = (event: PointerEvent) => {
dragState.active = true;
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
dragState.pointerId = event.pointerId;
dragState.startX = event.clientX;
dragState.startY = event.clientY;
dragState.root = { ...rootPose };
container.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (dragState.mode === 'pan') {
rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
return;
}
rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
};
const stopPointerDrag = (event: PointerEvent) => {
if (event.pointerId !== dragState.pointerId) return;
dragState.active = false;
if (container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
};
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.45, 2.2);
};
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
container.addEventListener('pointerdown', handlePointerDown);
container.addEventListener('pointermove', handlePointerMove);
container.addEventListener('pointerup', stopPointerDrag);
container.addEventListener('pointercancel', stopPointerDrag);
container.addEventListener('wheel', handleWheel, { passive: false });
container.addEventListener('contextmenu', preventContextMenu);
const handleResize = () => {
if (!container.clientWidth || !container.clientHeight) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
};
window.addEventListener('resize', handleResize);
const animate = () => {
if (disposed) return;
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
fusionRoot.scale.setScalar(rootPose.scale);
const pose = modelPoseRef.current;
modelPoseGroup.rotation.set(
THREE.MathUtils.degToRad(pose.rotateX),
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
);
modelPoseGroup.position.set(
pose.translateX,
pose.translateY,
dicomDepth * 0.08 + pose.translateZ,
);
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
animate();
return () => {
disposed = true;
window.cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', stopPointerDrag);
container.removeEventListener('pointercancel', stopPointerDrag);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('contextmenu', preventContextMenu);
textures.forEach((texture) => texture.dispose());
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
const material = object.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
}
});
renderer.dispose();
container.innerHTML = '';
};
}, [project.id, project.stlFiles?.join('|'), volume]);
return (
<canvas
ref={canvasRef}
width={preview.width}
height={preview.height}
className="absolute inset-0 h-full w-full object-contain opacity-80"
/>
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
<div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60">
{status}
</div>
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
</div>
{loadProgress < 100 && (
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
<span></span>
<span>{loadProgress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${loadProgress}%` }} />
</div>
</div>
)}
{!volume && (
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
DICOM ...
</div>
)}
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [slice, setSlice] = useState(50);
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [isRegistering, setIsRegistering] = useState(false);
const [progress, setProgress] = useState(0);
const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
const [project, setProject] = useState<Project | null>(null);
const [fusionPreview, setFusionPreview] = useState<DicomPreview | null>(null);
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState('');
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState('准备就绪');
const [mappings, setMappings] = useState<MaskMapping[]>([
const [mappings] = useState<MaskMapping[]>([
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
{ className: '神经根', color: '#52c41a', maskId: 2 },
{ className: '血管', color: '#1890ff', maskId: 3 },
@@ -85,32 +439,55 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
api.getProject(projectId).then((item) => {
setProject(item);
const middleSlice = Math.floor((item.dicomCount || 1) / 2);
setSlice(middleSlice);
return api.getDicomPreview(item.id, middleSlice, 'axial', 'soft');
}).then(setFusionPreview).catch(() => {
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
setSliceStart(0);
setSliceEnd(end);
setModelPose(defaultModelPose);
}).catch(() => {
setProject(null);
setFusionPreview(null);
setFusionVolume(null);
});
}, [projectId]);
useEffect(() => {
if (!project?.dicomCount) return;
const maxSlice = Math.max(project.dicomCount - 1, 0);
const safeStart = clamp(Math.min(sliceStart, sliceEnd), 0, maxSlice);
const safeEnd = clamp(Math.max(sliceStart, sliceEnd), safeStart, maxSlice);
const timer = window.setTimeout(() => {
api.getDicomPreview(project.id, slice, 'axial', 'soft').then(setFusionPreview).catch(() => setFusionPreview(null));
setFusionError('');
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
.then(setFusionVolume)
.catch((error) => {
setFusionVolume(null);
setFusionError(error instanceof Error ? error.message : 'DICOM 融合体加载失败');
});
}, 180);
return () => window.clearTimeout(timer);
}, [project?.id, slice]);
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress(p => p + 2), 50);
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
return () => clearTimeout(timer);
} else if (progress >= 100) {
}
if (progress >= 100) {
setIsRegistering(false);
}
return undefined;
}, [isRegistering, progress]);
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
...current,
...partial,
}));
};
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const displayStart = Math.min(sliceStart, sliceEnd);
const displayEnd = Math.max(sliceStart, sliceEnd);
return (
<div className="h-full flex flex-col gap-6">
<div className="flex items-center justify-between">
@@ -125,7 +502,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
{!project && <p className="text-sm text-slate-500"> DICOM </p>}
</div>
<div className="flex gap-2">
<button
<button
onClick={handleStartRegistration}
disabled={isRegistering}
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
@@ -147,98 +524,146 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
{/* Left Column: Image Fusion (4/12) */}
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
<div className="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Rotate3d size={18} className="text-blue-500" />
</h3>
<span className="text-[10px] font-mono text-slate-400">Layer: {slice + 1}/{project?.dicomCount ?? 0}</span>
<span className="text-[10px] font-mono text-slate-400">
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
</span>
</div>
<div className="flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group">
<div className="absolute inset-0 z-0 flex items-center justify-center p-8">
<div className="relative aspect-square w-full max-w-[460px] overflow-hidden rounded-2xl border border-white/10 bg-black">
{fusionPreview ? (
<FusionDicomCanvas preview={fusionPreview} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-white/40"> DICOM...</div>
)}
<div
className="absolute left-1/2 top-1/2 h-[58%] w-[58%] -translate-x-1/2 -translate-y-1/2 rounded-[46%_54%_44%_56%] border-2 border-blue-400/90 bg-blue-500/20 shadow-[0_0_40px_rgba(59,130,246,0.35)]"
style={{ transform: `translate(calc(-50% + ${offset[0] * 5}px), -50%)` }}
/>
<div className="absolute left-1/2 top-1/2 h-[64%] w-[64%] -translate-x-1/2 -translate-y-1/2 rounded-[52%_48%_57%_43%] border border-emerald-300/70 bg-emerald-400/10" />
<div className="absolute inset-x-0 top-1/2 h-px bg-cyan-400/25" />
<div className="absolute inset-y-0 left-1/2 w-px bg-cyan-400/25" />
</div>
</div>
<div className="absolute left-4 top-4 z-20 rounded-xl bg-black/60 px-3 py-2 text-[10px] font-mono text-white/50">
DICOM STL
</div>
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
<div className="pointer-events-auto bg-black/60 backdrop-blur-md border border-white/10 p-3 rounded-xl w-48 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-white uppercase opacity-60"></span>
<Settings2 size={10} className="text-blue-400" />
</div>
<input
type="range" min="-5" max="5" step="0.1"
value={offset[0]}
onChange={(e) => setOffset([Number(e.target.value), offset[1], offset[2]])}
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
/>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-white uppercase opacity-60"></span>
<span className="text-[9px] text-blue-300">{slice + 1}/{project?.dicomCount ?? 0}</span>
</div>
{project ? (
<FusionThreeView
project={project}
volume={fusionVolume}
modelPose={modelPose}
onModelPoseChange={setModelPose}
/>
) : (
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
...
</div>
)}
{fusionError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 flex items-center gap-2">
<AlertCircle size={16} />
{fusionError}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">DICOM </p>
<span className="text-[10px] font-mono text-blue-600">
{displayStart + 1} - {displayEnd + 1}
</span>
</div>
<div className="space-y-3">
<label className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={Math.max((project?.dicomCount ?? 1) - 1, 0)}
value={slice}
onChange={(e) => setSlice(Number(e.target.value))}
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
max={maxSlice}
value={sliceStart}
onChange={(event) => setSliceStart(Number(event.target.value))}
className="accent-blue-600"
/>
</div>
<span className="text-right font-mono">{sliceStart + 1}</span>
</label>
<label className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={sliceEnd}
onChange={(event) => setSliceEnd(Number(event.target.value))}
className="accent-blue-600"
/>
<span className="text-right font-mono">{sliceEnd + 1}</span>
</label>
</div>
<p className="mt-3 text-[10px] leading-5 text-slate-400">
DICOM CT
</p>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">姿</p>
<button
onClick={() => setModelPose(defaultModelPose)}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
</div>
<div className="space-y-2">
{[
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
].map((item) => (
<label key={item.key} className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
{item.label}
<input
type="range"
min={item.min}
max={item.max}
step={item.step}
value={item.value}
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
className="accent-blue-600"
/>
<span className="text-right font-mono">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
</label>
))}
</div>
</div>
</div>
</div>
{/* Middle Column: Mask Selection (3/12) */}
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
<div className="px-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Layers size={18} className="text-emerald-500" />
Mask
Mask
</h3>
</div>
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
<div className="flex-1 overflow-auto space-y-2 pr-1">
{mappings.map((m, i) => (
<button
key={i}
{mappings.map((mapping, index) => (
<button
key={mapping.maskId}
className={`w-full flex flex-col gap-2 p-3 rounded-xl border transition-all text-left group ${
i === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
index === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: m.color }} />
<span className="text-xs font-bold text-slate-700">{m.className}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: mapping.color }} />
<span className="text-xs font-bold text-slate-700">{mapping.className}</span>
</div>
{i === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
{index === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
</div>
<div className="flex items-center justify-between text-[10px] text-slate-500 font-mono">
<span>ID: {m.maskId}</span>
<span>ID: {mapping.maskId}</span>
<span className="font-bold text-emerald-600">Conf: 98%</span>
</div>
</button>
))}
<button className="w-full py-3 border-2 border-dashed border-slate-100 rounded-xl text-slate-400 flex items-center justify-center hover:bg-slate-50 transition-all">
<Plus size={18} />
</button>
@@ -256,93 +681,77 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
{/* Right Column: Mask Image Display (5/12) */}
<div className="lg:col-span-5 flex flex-col gap-4 overflow-hidden">
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Play size={18} className="text-blue-500" />
Mask
Mask
</h3>
<div className="flex gap-2">
<button
onClick={() => handleExport('nii')}
disabled={exporting}
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
<Download size={12} />
NII ()
</button>
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
<Download size={12} />
NII.GZ ()
</button>
<button
onClick={() => handleExport('nii')}
disabled={exporting}
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
<Download size={12} />
NII
</button>
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
<Download size={12} />
NII.GZ
</button>
</div>
</div>
<div className="flex-1 bg-slate-900 rounded-3xl border border-slate-800 shadow-2xl relative overflow-hidden flex items-center justify-center">
{/* The actual Mask result visualization */}
<div className="relative w-72 h-72">
{/* Base DICOM context (faint) */}
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
{/* Mask Layers */}
{mappings.map((m, i) => (
<motion.div
key={i}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 0.8 }}
transition={{ delay: i * 0.2 }}
className="absolute inset-0 border-2"
style={{
borderColor: m.color,
borderRadius: i === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
background: `${m.color}20`,
boxShadow: `inset 0 0 20px ${m.color}40`,
transform: `rotate(${i * 45 + slice}deg) scale(${1 - i * 0.1})`
}}
/>
))}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-0.5 bg-blue-500/20 absolute" />
<div className="h-full w-0.5 bg-blue-500/20 absolute" />
</div>
<div className="relative w-64 h-64">
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
{mappings.map((mapping, index) => (
<motion.div
key={mapping.maskId}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 0.8 }}
transition={{ delay: index * 0.2 }}
className="absolute inset-0 border-2"
style={{
borderColor: mapping.color,
borderRadius: index === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
background: `${mapping.color}20`,
boxShadow: `inset 0 0 20px ${mapping.color}40`,
transform: `rotate(${index * 45 + displayStart}deg) scale(${1 - index * 0.1})`,
}}
/>
))}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-0.5 bg-blue-500/20 absolute" />
<div className="h-full w-0.5 bg-blue-500/20 absolute" />
</div>
</div>
<div className="absolute top-4 left-4 z-20 flex gap-2">
<span className="px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase">Inferred Mask</span>
<span className="px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase">Verified</span>
<span className="px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase">Inferred Mask</span>
<span className="px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase">Verified</span>
</div>
<div className="absolute bottom-4 right-4">
<button className="p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all">
<Maximize2 size={16} />
</button>
</div>
{/* Legend Overlay */}
<div className="absolute top-4 right-4 flex flex-col gap-1 items-end">
{mappings.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-[9px] text-white/40 font-mono italic">#{m.maskId}</span>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: m.color }} />
</div>
))}
<button className="p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all">
<Maximize2 size={16} />
</button>
</div>
</div>
<div className="h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-xs font-bold text-slate-700">{exportMessage} {mappings.length} </span>
</div>
<div className="w-32 bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-600 h-full w-[100%]" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-xs font-bold text-slate-700">{exportMessage} {mappings.length} </span>
</div>
<div className="w-24 bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-600 h-full w-full" />
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { DicomInfo, DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types';
import { DicomFusionVolume, DicomInfo, DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
@@ -52,6 +52,8 @@ export const api = {
}),
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
request<DicomFusionVolume>(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`),
getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`),
getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () =>

View File

@@ -79,6 +79,28 @@ export interface DicomPreview {
};
}
export interface DicomFusionVolume {
width: number;
height: number;
start: number;
end: number;
total: number;
indices: number[];
frames: string[];
mode: DicomPreview['mode'];
spacing: {
row: number;
column: number;
slice: number;
};
physicalSize: {
width: number;
height: number;
depth: number;
unit: string;
};
}
export interface DicomInfo {
project: {
id: string;