From cbac61eabc609ca818eb5eb9022531b6af764980 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Thu, 7 May 2026 17:36:34 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-07-17-28-34=20=E5=AE=9E=E7=8E=B0DICOM?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9E=8B=E4=B8=89=E7=BB=B4=E8=9E=8D=E5=90=88?= =?UTF-8?q?=E8=A7=86=E8=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 72 ++ WebSite/src/components/ReverseWorkspace.tsx | 755 +++++++++++++++----- WebSite/src/lib/api.ts | 4 +- WebSite/src/types.ts | 22 + 工程分析/实现方案-2026-05-07-17-28-34.md | 119 +++ 工程分析/测试方案-2026-05-07-17-28-34.md | 51 ++ 工程分析/经验记录.md | 18 + 工程分析/需求分析-2026-05-07-17-28-34.md | 63 ++ 8 files changed, 930 insertions(+), 174 deletions(-) create mode 100644 工程分析/实现方案-2026-05-07-17-28-34.md create mode 100644 工程分析/测试方案-2026-05-07-17-28-34.md create mode 100644 工程分析/需求分析-2026-05-07-17-28-34.md diff --git a/WebSite/server.ts b/WebSite/server.ts index a53610d..d161d04 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -618,6 +618,53 @@ function createReformattedPreview(files: string[], plane: Exclude ( + 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) { diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 21b083b..7f60e15 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -1,64 +1,418 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { motion } from 'motion/react'; -import { - Dices, - Settings2, - Maximize2, - Download, - Layers, - Move, +import { + Dices, + Settings2, + Maximize2, + Download, + Layers, Rotate3d, CheckCircle2, AlertCircle, FileJson, Plus, - Play + Play, } from 'lucide-react'; -import { DicomPreview, MaskMapping, Project } from '../types'; +import * as THREE from 'three'; +import { DicomFusionVolume, MaskMapping, Project } from '../types'; import { api, downloadMask } from '../lib/api'; -function FusionDicomCanvas({ preview }: { preview: DicomPreview }) { - const canvasRef = useRef(null); +interface ModelPose { + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + translateZ: number; + scale: number; +} + +interface ModelPreviewPayload { + fileName: string; + triangleCount: number; + sampledTriangles: number; + vertices: number[]; + bounds?: { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; + }; +} + +const defaultModelPose: ModelPose = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, +}; + +const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function createDicomTexture(frame: string, width: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + + const binary = atob(frame); + const imageData = context.createImageData(width, height); + for (let index = 0; index < binary.length; index += 1) { + const value = binary.charCodeAt(index); + const offset = index * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = value > 4 ? 235 : 0; + } + context.putImageData(imageData, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + return texture; +} + +function FusionThreeView({ + project, + volume, + modelPose, + onModelPoseChange, +}: { + project: Project; + volume: DicomFusionVolume | null; + modelPose: ModelPose; + onModelPoseChange: React.Dispatch>; +}) { + const containerRef = useRef(null); + const modelPoseRef = useRef(modelPose); + const onModelPoseChangeRef = useRef(onModelPoseChange); + const [status, setStatus] = useState('准备融合 DICOM 与 STL'); + const [loadProgress, setLoadProgress] = useState(0); useEffect(() => { - const canvas = canvasRef.current; - const context = canvas?.getContext('2d'); - if (!canvas || !context) return; + modelPoseRef.current = modelPose; + }, [modelPose]); - const binary = atob(preview.pixels); - const imageData = context.createImageData(preview.width, preview.height); - for (let i = 0; i < binary.length; i += 1) { - const value = binary.charCodeAt(i); - const offset = i * 4; - imageData.data[offset] = value; - imageData.data[offset + 1] = value; - imageData.data[offset + 2] = value; - imageData.data[offset + 3] = 255; - } - context.putImageData(imageData, 0, 0); - }, [preview]); + useEffect(() => { + onModelPoseChangeRef.current = onModelPoseChange; + }, [onModelPoseChange]); + + useEffect(() => { + const container = containerRef.current; + if (!container || !volume) return; + + container.innerHTML = ''; + setStatus('正在构建三维融合场景...'); + setLoadProgress(8); + + let disposed = false; + let animationId = 0; + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#030712'); + + const width = Math.max(container.clientWidth, 1); + const height = Math.max(container.clientHeight, 1); + const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000); + camera.position.set(0, -6.2, 4.6); + camera.up.set(0, 0, 1); + camera.lookAt(0, 0, 0); + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(width, height); + container.appendChild(renderer.domElement); + + scene.add(new THREE.AmbientLight(0xffffff, 0.72)); + const keyLight = new THREE.DirectionalLight(0xffffff, 1.1); + keyLight.position.set(4, -5, 5); + scene.add(keyLight); + const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55); + fillLight.position.set(-4, 3, 2); + scene.add(fillLight); + + const fusionRoot = new THREE.Group(); + const dicomGroup = new THREE.Group(); + const modelPoseGroup = new THREE.Group(); + const modelPivot = new THREE.Group(); + modelPoseGroup.add(modelPivot); + fusionRoot.add(dicomGroup); + fusionRoot.add(modelPoseGroup); + scene.add(fusionRoot); + + const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1); + const baseExtent = 4.6; + const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent; + const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent; + const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18); + const planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight); + + const box = new THREE.Mesh( + new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth), + new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: 0.32, depthWrite: false }), + ); + dicomGroup.add(box); + const edges = new THREE.LineSegments( + new THREE.EdgesGeometry(box.geometry), + new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }), + ); + dicomGroup.add(edges); + + const textures: THREE.Texture[] = []; + volume.frames.forEach((frame, index) => { + const texture = createDicomTexture(frame, volume.width, volume.height); + if (!texture) return; + textures.push(texture); + const isLast = index === volume.frames.length - 1; + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: isLast ? 0.82 : 0.12, + side: THREE.DoubleSide, + depthWrite: false, + }); + const slicePlane = new THREE.Mesh(planeGeometry, material); + const z = volume.frames.length <= 1 + ? 0 + : -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1); + slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z); + dicomGroup.add(slicePlane); + }); + setLoadProgress(42); + + const stlFiles = project.stlFiles ?? []; + let modelBaseScale = 1; + let loadedModels = 0; + let failedModels = 0; + const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; + + Promise.allSettled(stlFiles.map((fileName, index) => ( + fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=72000`) + .then((response) => { + if (!response.ok) throw new Error('模型预览加载失败'); + return response.json() as Promise; + }) + .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 ( - +
+
+
+ {status} +
+
+ DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0} +
+ {loadProgress < 100 && ( +
+
+ 正在融合三维影像与模型 + {loadProgress}% +
+
+
+
+
+ )} + {!volume && ( +
+ 正在载入 DICOM 三维体... +
+ )} +
); } 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(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(null); - const [fusionPreview, setFusionPreview] = useState(null); + const [fusionVolume, setFusionVolume] = useState(null); + const [fusionError, setFusionError] = useState(''); const [exporting, setExporting] = useState(false); const [exportMessage, setExportMessage] = useState('准备就绪'); - - const [mappings, setMappings] = useState([ + + const [mappings] = useState([ { 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) => { + 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 (
@@ -125,7 +502,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { {!project &&

配准 DICOM 影像与三维模型,生成像素映射关系

}
- +
+
+ {[ + { 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) => ( + + ))} +
- {/* Middle Column: Mask Selection (3/12) */} -
+

- 分割 Mask 选择 + 分割 Mask

- {mappings.map((m, i) => ( - ))} - + @@ -256,93 +681,77 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
- {/* Right Column: Mask Image Display (5/12) */} -
+

- 分割 Mask 图片展示 + Mask 展示

- - + +
- {/* The actual Mask result visualization */} -
- {/* Base DICOM context (faint) */} -
- - {/* Mask Layers */} - {mappings.map((m, i) => ( - - ))} - -
-
-
-
+
+
+ {mappings.map((mapping, index) => ( + + ))} +
+
+
+
- Inferred Mask - Verified + Inferred Mask + Verified
- -
- - {/* Legend Overlay */} -
- {mappings.map((m, i) => ( -
- #{m.maskId} -
-
- ))} +
-
- 导出进度 - {exportMessage},包含 {mappings.length} 个标注层级 -
-
-
-
+
+ 导出进度 + {exportMessage},包含 {mappings.length} 个标注层级 +
+
+
+
diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 640627e..06cd7f6 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { 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(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`), + getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') => + request(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`), getDicomInfo: (projectId: string) => request(`/api/projects/${projectId}/dicom-info`), getUsers: () => request('/api/users'), resetDemo: () => diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index 009c13b..f233845 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -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; diff --git a/工程分析/实现方案-2026-05-07-17-28-34.md b/工程分析/实现方案-2026-05-07-17-28-34.md new file mode 100644 index 0000000..b5303b0 --- /dev/null +++ b/工程分析/实现方案-2026-05-07-17-28-34.md @@ -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 模型整体旋转、平移、缩放。 diff --git a/工程分析/测试方案-2026-05-07-17-28-34.md b/工程分析/测试方案-2026-05-07-17-28-34.md new file mode 100644 index 0000000..02fa6ea --- /dev/null +++ b/工程分析/测试方案-2026-05-07-17-28-34.md @@ -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。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 1d93a73..79316e4 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.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 的调整和整体场景观察要分开管理。 diff --git a/工程分析/需求分析-2026-05-07-17-28-34.md b/工程分析/需求分析-2026-05-07-17-28-34.md new file mode 100644 index 0000000..2c240fa --- /dev/null +++ b/工程分析/需求分析-2026-05-07-17-28-34.md @@ -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 展示等内容,融合视角变为三维后需要保持布局可用。 + +## 待确认问题 + +- 本次用户已明确免二次确认,直接执行。 + +## 人工审核状态 + +- 本次免二次确认。