2026-05-07-17-28-34 实现DICOM与模型三维融合视角
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Dices,
|
||||
@@ -6,59 +6,413 @@ import {
|
||||
Maximize2,
|
||||
Download,
|
||||
Layers,
|
||||
Move,
|
||||
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;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext('2d');
|
||||
if (!canvas || !context) return;
|
||||
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 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;
|
||||
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] = 255;
|
||||
imageData.data[offset + 3] = value > 4 ? 235 : 0;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}, [preview]);
|
||||
|
||||
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(() => {
|
||||
modelPoseRef.current = modelPose;
|
||||
}, [modelPose]);
|
||||
|
||||
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">
|
||||
@@ -147,93 +524,141 @@ 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} />
|
||||
{project ? (
|
||||
<FusionThreeView
|
||||
project={project}
|
||||
volume={fusionVolume}
|
||||
modelPose={modelPose}
|
||||
onModelPoseChange={setModelPose}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-white/40">正在载入 DICOM...</div>
|
||||
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
|
||||
正在载入项目...
|
||||
</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" />
|
||||
{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>
|
||||
<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 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"
|
||||
/>
|
||||
<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) => (
|
||||
{mappings.map((mapping, index) => (
|
||||
<button
|
||||
key={i}
|
||||
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>
|
||||
@@ -256,12 +681,11 @@ 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
|
||||
@@ -270,7 +694,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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 (单帧)
|
||||
NII
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('nii.gz')}
|
||||
@@ -278,35 +702,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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 (全量)
|
||||
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="relative w-64 h-64">
|
||||
<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) => (
|
||||
{mappings.map((mapping, index) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
key={mapping.maskId}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.8 }}
|
||||
transition={{ delay: i * 0.2 }}
|
||||
transition={{ delay: index * 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})`
|
||||
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" />
|
||||
@@ -323,16 +742,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<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>
|
||||
))}
|
||||
</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">
|
||||
@@ -340,8 +749,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<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 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>
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
119
工程分析/实现方案-2026-05-07-17-28-34.md
Normal file
119
工程分析/实现方案-2026-05-07-17-28-34.md
Normal 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 模型整体旋转、平移、缩放。
|
||||
51
工程分析/测试方案-2026-05-07-17-28-34.md
Normal file
51
工程分析/测试方案-2026-05-07-17-28-34.md
Normal 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。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -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 的调整和整体场景观察要分开管理。
|
||||
|
||||
63
工程分析/需求分析-2026-05-07-17-28-34.md
Normal file
63
工程分析/需求分析-2026-05-07-17-28-34.md
Normal 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 展示等内容,融合视角变为三维后需要保持布局可用。
|
||||
|
||||
## 待确认问题
|
||||
|
||||
- 本次用户已明确免二次确认,直接执行。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 本次免二次确认。
|
||||
Reference in New Issue
Block a user