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) {
|
function enhanceDicomEdges(pixels: Buffer, width: number, height: number) {
|
||||||
if (width < 3 || height < 3) {
|
if (width < 3 || height < 3) {
|
||||||
return pixels;
|
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) => {
|
app.get('/api/projects/:projectId/dicom-archive', (req, res) => {
|
||||||
const project = findProject(readState(), req.params.projectId);
|
const project = findProject(readState(), req.params.projectId);
|
||||||
if (!project) {
|
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 { motion } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
Dices,
|
Dices,
|
||||||
@@ -6,59 +6,413 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Download,
|
Download,
|
||||||
Layers,
|
Layers,
|
||||||
Move,
|
|
||||||
Rotate3d,
|
Rotate3d,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FileJson,
|
FileJson,
|
||||||
Plus,
|
Plus,
|
||||||
Play
|
Play,
|
||||||
} from 'lucide-react';
|
} 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';
|
import { api, downloadMask } from '../lib/api';
|
||||||
|
|
||||||
function FusionDicomCanvas({ preview }: { preview: DicomPreview }) {
|
interface ModelPose {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
rotateX: number;
|
||||||
|
rotateY: number;
|
||||||
|
rotateZ: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
translateZ: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
interface ModelPreviewPayload {
|
||||||
const canvas = canvasRef.current;
|
fileName: string;
|
||||||
const context = canvas?.getContext('2d');
|
triangleCount: number;
|
||||||
if (!canvas || !context) return;
|
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 defaultModelPose: ModelPose = {
|
||||||
const imageData = context.createImageData(preview.width, preview.height);
|
rotateX: 0,
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
rotateY: 0,
|
||||||
const value = binary.charCodeAt(i);
|
rotateZ: 0,
|
||||||
const offset = i * 4;
|
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] = value;
|
||||||
imageData.data[offset + 1] = value;
|
imageData.data[offset + 1] = value;
|
||||||
imageData.data[offset + 2] = value;
|
imageData.data[offset + 2] = value;
|
||||||
imageData.data[offset + 3] = 255;
|
imageData.data[offset + 3] = value > 4 ? 235 : 0;
|
||||||
}
|
}
|
||||||
context.putImageData(imageData, 0, 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 (
|
return (
|
||||||
<canvas
|
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
||||||
ref={canvasRef}
|
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
||||||
width={preview.width}
|
<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">
|
||||||
height={preview.height}
|
{status}
|
||||||
className="absolute inset-0 h-full w-full object-contain opacity-80"
|
</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 }) {
|
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 [isRegistering, setIsRegistering] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
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 [exporting, setExporting] = useState(false);
|
||||||
const [exportMessage, setExportMessage] = useState('准备就绪');
|
const [exportMessage, setExportMessage] = useState('准备就绪');
|
||||||
|
|
||||||
const [mappings, setMappings] = useState<MaskMapping[]>([
|
const [mappings] = useState<MaskMapping[]>([
|
||||||
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
|
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
|
||||||
{ className: '神经根', color: '#52c41a', maskId: 2 },
|
{ className: '神经根', color: '#52c41a', maskId: 2 },
|
||||||
{ className: '血管', color: '#1890ff', maskId: 3 },
|
{ className: '血管', color: '#1890ff', maskId: 3 },
|
||||||
@@ -85,32 +439,55 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getProject(projectId).then((item) => {
|
api.getProject(projectId).then((item) => {
|
||||||
setProject(item);
|
setProject(item);
|
||||||
const middleSlice = Math.floor((item.dicomCount || 1) / 2);
|
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
|
||||||
setSlice(middleSlice);
|
setSliceStart(0);
|
||||||
return api.getDicomPreview(item.id, middleSlice, 'axial', 'soft');
|
setSliceEnd(end);
|
||||||
}).then(setFusionPreview).catch(() => {
|
setModelPose(defaultModelPose);
|
||||||
|
}).catch(() => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setFusionPreview(null);
|
setFusionVolume(null);
|
||||||
});
|
});
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project?.dicomCount) return;
|
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(() => {
|
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);
|
}, 180);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [project?.id, slice]);
|
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRegistering && progress < 100) {
|
if (isRegistering && progress < 100) {
|
||||||
const timer = setTimeout(() => setProgress(p => p + 2), 50);
|
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
} else if (progress >= 100) {
|
}
|
||||||
|
if (progress >= 100) {
|
||||||
setIsRegistering(false);
|
setIsRegistering(false);
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}, [isRegistering, progress]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full flex flex-col gap-6">
|
<div className="h-full flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -147,93 +524,141 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
|
<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-7 flex flex-col gap-4 overflow-hidden">
|
||||||
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
|
|
||||||
<div className="px-2 flex items-center justify-between shrink-0">
|
<div className="px-2 flex items-center justify-between shrink-0">
|
||||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||||
<Rotate3d size={18} className="text-blue-500" />
|
<Rotate3d size={18} className="text-blue-500" />
|
||||||
影像与模型融合视角
|
影像与模型融合视角
|
||||||
</h3>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group">
|
{project ? (
|
||||||
<div className="absolute inset-0 z-0 flex items-center justify-center p-8">
|
<FusionThreeView
|
||||||
<div className="relative aspect-square w-full max-w-[460px] overflow-hidden rounded-2xl border border-white/10 bg-black">
|
project={project}
|
||||||
{fusionPreview ? (
|
volume={fusionVolume}
|
||||||
<FusionDicomCanvas preview={fusionPreview} />
|
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">
|
{fusionError && (
|
||||||
<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="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">
|
||||||
<div className="flex items-center justify-between">
|
<AlertCircle size={16} />
|
||||||
<span className="text-[9px] font-bold text-white uppercase opacity-60">微调</span>
|
{fusionError}
|
||||||
<Settings2 size={10} className="text-blue-400" />
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
)}
|
||||||
type="range" min="-5" max="5" step="0.1"
|
|
||||||
value={offset[0]}
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
onChange={(e) => setOffset([Number(e.target.value), offset[1], offset[2]])}
|
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||||||
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
|
<div className="mb-3 flex items-center justify-between">
|
||||||
/>
|
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-[10px] font-mono text-blue-600">
|
||||||
<span className="text-[9px] font-bold text-white uppercase opacity-60">切片</span>
|
{displayStart + 1} - {displayEnd + 1}
|
||||||
<span className="text-[9px] text-blue-300">{slice + 1}/{project?.dicomCount ?? 0}</span>
|
</span>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max={Math.max((project?.dicomCount ?? 1) - 1, 0)}
|
max={maxSlice}
|
||||||
value={slice}
|
value={sliceStart}
|
||||||
onChange={(e) => setSlice(Number(e.target.value))}
|
onChange={(event) => setSliceStart(Number(event.target.value))}
|
||||||
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle Column: Mask Selection (3/12) */}
|
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
||||||
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
|
|
||||||
<div className="px-2 shrink-0">
|
<div className="px-2 shrink-0">
|
||||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||||
<Layers size={18} className="text-emerald-500" />
|
<Layers size={18} className="text-emerald-500" />
|
||||||
分割 Mask 选择
|
分割 Mask
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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 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">
|
<div className="flex-1 overflow-auto space-y-2 pr-1">
|
||||||
{mappings.map((m, i) => (
|
{mappings.map((mapping, index) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={mapping.maskId}
|
||||||
className={`w-full flex flex-col gap-2 p-3 rounded-xl border transition-all text-left group ${
|
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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: m.color }} />
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: mapping.color }} />
|
||||||
<span className="text-xs font-bold text-slate-700">{m.className}</span>
|
<span className="text-xs font-bold text-slate-700">{mapping.className}</span>
|
||||||
</div>
|
</div>
|
||||||
{i === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
|
{index === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[10px] text-slate-500 font-mono">
|
<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>
|
<span className="font-bold text-emerald-600">Conf: 98%</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -256,12 +681,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Mask Image Display (5/12) */}
|
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
|
||||||
<div className="lg:col-span-5 flex flex-col gap-4 overflow-hidden">
|
|
||||||
<div className="px-2 flex items-center justify-between shrink-0">
|
<div className="px-2 flex items-center justify-between shrink-0">
|
||||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||||
<Play size={18} className="text-blue-500" />
|
<Play size={18} className="text-blue-500" />
|
||||||
分割 Mask 图片展示
|
Mask 展示
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<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"
|
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} />
|
<Download size={12} />
|
||||||
NII (单帧)
|
NII
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExport('nii.gz')}
|
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"
|
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} />
|
<Download size={12} />
|
||||||
NII.GZ (全量)
|
NII.GZ
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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-64 h-64">
|
||||||
<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" />
|
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
{/* Mask Layers */}
|
|
||||||
{mappings.map((m, i) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={mapping.maskId}
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 0.8 }}
|
animate={{ scale: 1, opacity: 0.8 }}
|
||||||
transition={{ delay: i * 0.2 }}
|
transition={{ delay: index * 0.2 }}
|
||||||
className="absolute inset-0 border-2"
|
className="absolute inset-0 border-2"
|
||||||
style={{
|
style={{
|
||||||
borderColor: m.color,
|
borderColor: mapping.color,
|
||||||
borderRadius: i === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
|
borderRadius: index === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
|
||||||
background: `${m.color}20`,
|
background: `${mapping.color}20`,
|
||||||
boxShadow: `inset 0 0 20px ${m.color}40`,
|
boxShadow: `inset 0 0 20px ${mapping.color}40`,
|
||||||
transform: `rotate(${i * 45 + slice}deg) scale(${1 - i * 0.1})`
|
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="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="w-full h-0.5 bg-blue-500/20 absolute" />
|
||||||
<div className="h-full w-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} />
|
<Maximize2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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="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-[10px] font-bold text-slate-400 uppercase tracking-widest">导出进度</span>
|
||||||
<span className="text-xs font-bold text-slate-700">{exportMessage},包含 {mappings.length} 个标注层级</span>
|
<span className="text-xs font-bold text-slate-700">{exportMessage},包含 {mappings.length} 个标注层级</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32 bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
<div className="w-24 bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||||
<div className="bg-blue-600 h-full w-[100%]" />
|
<div className="bg-blue-600 h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(path, {
|
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') =>
|
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}`),
|
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`),
|
getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`),
|
||||||
getUsers: () => request<UserRecord[]>('/api/users'),
|
getUsers: () => request<UserRecord[]>('/api/users'),
|
||||||
resetDemo: () =>
|
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 {
|
export interface DicomInfo {
|
||||||
project: {
|
project: {
|
||||||
id: string;
|
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. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
模型显示档位应明确区分“性能抽样”和“实体查看”;若新增高质量档位,需要同步检查前端请求 limit、后端 clamp、材质表现和浏览器性能边界。
|
模型显示档位应明确区分“性能抽样”和“实体查看”;若新增高质量档位,需要同步检查前端请求 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