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;

View File

@@ -0,0 +1,119 @@
# 实现方案 - 2026-05-07-17-28-34
## 修改目标
在逆向工作区实现三维 DICOM 体与 STL 模型融合视角:
- DICOM 切片范围可控。
- DICOM 以黑色长方体和多张切片纹理展示。
- 最后一张切片显示在体表面。
- STL 模型叠加在 DICOM 体中。
- 用户可以调整模型位姿。
- DICOM 和 STL 可以一起旋转、平移、缩放观察。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/types.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/components/ReverseWorkspace.tsx`
## 技术路线
### 1. 后端新增 DICOM 融合体接口
新增接口:
```text
GET /api/projects/:projectId/dicom-fusion-volume?start=0&end=49&mode=soft
```
返回内容:
- `width``height`
- `start``end``total`
- `indices`
- `frames`:每张切片灰度图 base64
- `spacing`row/column/slice
- `physicalSize`width/height/depth
为控制传输和纹理压力:
- 单次最多返回 64 张切片。
- 纹理最大边长限制为 256。
- 使用已有 `getDicomVolume()``resampleNearest()`,避免重复实现 DICOM 解析。
### 2. 前端新增类型与 API
-`types.ts` 新增 `DicomFusionVolume`
-`api.ts` 新增 `getDicomFusionVolume(projectId, start, end, mode)`
### 3. 重构融合视角为 Three.js 场景
`ReverseWorkspace.tsx` 中新增 `FusionThreeView`
- 创建 Three.js scene/camera/renderer。
- DICOM
- 用半透明黑色 box 表示 CT 体。
- 将范围内切片转为 `CanvasTexture`
- 按切片索引在 Z 方向分层放置。
- 最后一帧贴到体表面并提高不透明度。
- STL
- 调用已有 STL preview 接口加载模型构件。
- 使用后端返回 `bounds` 合成稳定模型中心。
- 将模型挂到 `modelGroup`
- 场景:
- DICOM 体和模型都放入 `fusionRoot`,整体旋转和平移缩放作用在 `fusionRoot`
- 模型位姿单独作用在 `modelPoseGroup`
### 4. 交互与控制
- 鼠标左键拖拽:旋转整体融合场景。
- Shift/右键拖拽:平移整体融合场景。
- 滚轮:缩放整体融合场景。
- UI 控件:
- 切片起点、切片终点。
- 模型旋转 X/Y/Z。
- 模型平移 X/Y/Z。
- 模型缩放。
- 重置模型位姿。
## 数据流或交互流程
1. 进入逆向工作区后加载项目详情。
2. 根据默认切片范围请求 DICOM 融合体数据。
3. 请求 STL preview 并加载模型。
4. Three.js 创建 DICOM 体和 STL 模型。
5. 用户拖拽场景时 DICOM 与 STL 一起旋转。
6. 用户调整模型位姿时只改变 STL 模型相对 DICOM 的位置。
7. 用户调整切片范围时重新请求 DICOM 融合体数据并刷新 CT 体。
## 兼容性与回滚方案
- 若 WebGL 不可用,显示加载失败/不支持提示,不影响其他页面。
- 若融合体接口失败,保留逆向工作区其他 mask 导出功能。
- 若性能不足,可降低最大返回切片数和纹理尺寸。
## 预计文件变更
- `WebSite/server.ts`
- 新增 `createDicomFusionVolume()` 和 API route。
- `WebSite/src/types.ts`
- 新增 `DicomFusionVolume` 类型。
- `WebSite/src/lib/api.ts`
- 新增 `getDicomFusionVolume()`
- `WebSite/src/components/ReverseWorkspace.tsx`
- 新增 Three.js 融合视角组件和位姿控制。
## 人工审核状态
- 本次免二次确认,方案写入后直接执行。
## 执行结果
- 已新增 `GET /api/projects/:projectId/dicom-fusion-volume`,支持按 `start/end/mode` 返回一段轴向 DICOM 体数据纹理。
- 已新增 `DicomFusionVolume` 类型和 `api.getDicomFusionVolume()`
- 已将逆向工作区 `影像与模型融合视角` 重构为 Three.js 三维场景。
- 已实现黑色 DICOM 长方体、范围内切片纹理层叠、最后一帧体表面显示。
- 已将 STL 模型加载进同一融合场景,可通过模型位姿控件相对 DICOM 体调整。
- 已支持鼠标拖拽/滚轮对 DICOM 体和 STL 模型整体旋转、平移、缩放。

View File

@@ -0,0 +1,51 @@
# 测试方案 - 2026-05-07-17-28-34
## 静态检查
1. `git status --short --branch`
2. `cd WebSite && npm run build`
3. `cd WebSite && npm run lint`
## 单元或集成测试
当前项目没有独立单元测试体系本次采用构建、类型检查、API 冒烟和页面运行时验证。
## 关键业务场景验证
1. 打开 `http://192.168.3.11:4000/`
2. 进入 `逆向工作区`
3. 验证 `影像与模型融合视角` 出现三维融合场景。
4. 验证 DICOM 体是黑色长方体,体表面显示当前范围最后一张切片。
5. 调整切片起点/终点:
- 显示范围变化。
- 范围文本同步变化。
- 场景重新加载后仍可交互。
6. 验证 STL 模型叠加在 CT 体上。
7. 鼠标拖拽整体场景:
- DICOM 体和 STL 模型一起旋转。
8. 调整模型位姿:
- 只有 STL 模型相对 DICOM 体移动或旋转。
9. 验证 `开始自动配准``导出 NII.GZ` 按钮仍可操作。
## 医学影像数据相关边界验证
- 融合接口最多返回 64 张切片,避免过量纹理。
- 切片起止范围需要 clamp 到 `[0, dicomCount - 1]`
- DICOM spacing 与 physicalSize 需要从现有体数据缓存中输出。
## 回归风险
- Three.js 纹理和 STL 共同渲染会增加 GPU 压力。
- 逆向工作区布局变更可能影响 Mask 选择和导出信息。
- 当前融合为归一化同场景叠加,不是最终医学空间刚性配准矩阵。
## 人工审核状态
- 本次免二次确认。
## 执行记录
- `npm run lint`:通过,实际执行 `tsc --noEmit`
- `npm run build`:通过。
- 重新部署后 `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`
- 重新部署后请求 `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=0&end=49&mode=soft`:返回 `width=256``height=256``start=0``end=49``total=300`、50 个切片索引、spacing 和 physicalSize。

View File

@@ -631,3 +631,21 @@ C. 解决问题方案
D. 后续如何避免问题
模型显示档位应明确区分“性能抽样”和“实体查看”;若新增高质量档位,需要同步检查前端请求 limit、后端 clamp、材质表现和浏览器性能边界。
## 2026-05-07-17-28-34 逆向工作区三维融合视角
A. 具体问题
逆向工作区原来的 `影像与模型融合视角` 只是二维 DICOM 图片和示意轮廓叠加,不能表现 DICOM 体数据、切片范围、STL 模型同场景融合,也不能让 DICOM 和模型作为整体旋转观察。
B. 产生问题原因
早期实现更偏演示界面,没有后端接口提供一段 DICOM 体数据纹理;前端也没有 Three.js 融合场景,只是在二维 canvas 上放置静态标注层。
C. 解决问题方案
新增 `dicom-fusion-volume` 后端接口,按起止切片返回最多 64 张、最大 256 像素边长的轴向 CT 纹理和 spacing/physicalSize前端逆向工作区新增 Three.js 融合场景,将 DICOM 渲染为黑色长方体和切片纹理,将 STL preview 模型加载到同一场景,并提供切片范围与模型位姿控件。
D. 后续如何避免问题
融合、配准、体素化相关视图应优先使用三维数据结构而不是二维示意图DICOM 体数据接口必须限制切片数量和纹理尺寸,保证浏览器交互稳定;模型相对 DICOM 的调整和整体场景观察要分开管理。

View File

@@ -0,0 +1,63 @@
# 需求分析 - 2026-05-07-17-28-34
## 原始需求摘要
用户要求在 `逆向工作区 - 影像与模型融合视角` 中实现真实的 DICOM 与 3D 模型融合浏览:
1. 将 DICOM 转为三维影像体。
2. DICOM 可以按切片范围显示,例如显示第 1 到第 50 张。
3. DICOM 在三维中表现为一个黑色长方体,表面显示待显示范围的最后一帧图片。
4. 3D 模型叠加在 DICOM 体上方。
5. 可以调整模型位姿。
6. DICOM 与模型可以一起旋转,最终达到模型显示在 CT 上的效果。
7. 本次需求分析、实现方案、测试方案、执行修改都不需要人工二次确认。
## 业务目标
- 将逆向工作区从二维示意融合升级为三维 DICOM 体与 STL 模型同场景融合。
- 为后续模型逆向体素化和 DICOM 分割标注提供更接近真实配准场景的交互基础。
- 支持用户通过切片范围控制 CT 体显示,并通过模型位姿微调对齐模型。
## 输入与输出
输入:
- 当前项目 `Head_CT_DICOM` 的 DICOM 切片序列。
- 当前项目 `Head_CT_ReConstruct` 的 STL 模型构件。
- 用户选择的 DICOM 切片起止范围。
- 用户拖拽/滚轮旋转缩放整体场景,以及通过控件调整模型位姿。
输出:
- 逆向工作区中出现三维融合视角。
- DICOM 以黑色体数据长方体展示,最后一帧贴在体表面。
- 选定范围内的 CT 切片以半透明层叠方式呈现。
- STL 模型叠加在 CT 体上,可单独调整模型位姿。
- DICOM 体和 STL 模型作为一个场景整体旋转查看。
## 影响范围
- `WebSite/server.ts`
- 新增 DICOM 融合体数据接口。
- `WebSite/src/types.ts`
- 新增融合体数据类型。
- `WebSite/src/lib/api.ts`
- 新增融合体数据请求方法。
- `WebSite/src/components/ReverseWorkspace.tsx`
- 重构影像与模型融合视角为 Three.js 三维融合场景。
- 新增切片范围控制、模型位姿控制、整体视角交互。
## 风险点
- 一次性加载过多 DICOM 切片会导致接口响应和 WebGL 纹理压力较大。
- STL 与 DICOM 的真实坐标系还没有完整医学空间配准矩阵,本次属于同场景归一化融合和手动位姿调整。
- 透明 CT 切片过多可能遮挡模型,需要控制默认范围和透明度。
- 逆向工作区当前布局还有 Mask 展示等内容,融合视角变为三维后需要保持布局可用。
## 待确认问题
- 本次用户已明确免二次确认,直接执行。
## 人工审核状态
- 本次免二次确认。