diff --git a/WebSite/server.ts b/WebSite/server.ts index 286f254..710b7ee 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -7,6 +7,8 @@ import zlib from 'node:zlib'; import { fileURLToPath } from 'node:url'; type ProjectStatus = 'pending' | 'completed' | 'processing'; +type DicomPlane = 'axial' | 'sagittal' | 'coronal'; +type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast'; interface UserRecord { id: number; @@ -54,6 +56,15 @@ const exportDir = path.join(__dirname, 'exports'); const statePath = path.join(dataDir, 'state.json'); const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); +const dicomPreviewCache = new Map(); +const dicomVolumeCache = new Map(); +const modelPreviewCache = new Map(); function today() { return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()); @@ -302,7 +313,20 @@ function findExplicitTag(buffer: Buffer, group: number, element: number) { return null; } -function parseDicomPreview(filePath: string) { +function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fallbackWidth: number) { + if (mode === 'bone') { + return { windowCenter: 500, windowWidth: 2000 }; + } + if (mode === 'soft') { + return { windowCenter: 40, windowWidth: 400 }; + } + if (mode === 'contrast') { + return { windowCenter: 80, windowWidth: 180 }; + } + return { windowCenter: fallbackCenter, windowWidth: fallbackWidth }; +} + +function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') { const buffer = fs.readFileSync(filePath); const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010); const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011); @@ -318,8 +342,9 @@ function parseDicomPreview(filePath: string) { const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0; const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16; const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0; - const windowCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40; - const windowWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400; + const fallbackCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40; + const fallbackWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400; + const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth); const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0; const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1; const pixelOffset = pixelTag?.valueOffset ?? -1; @@ -343,7 +368,10 @@ function parseDicomPreview(filePath: string) { ? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) : buffer.readUInt8(position); const hu = raw * rescaleSlope + rescaleIntercept; - const normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255))); + let normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255))); + if (mode === 'contrast') { + normalized = Math.max(0, Math.min(255, Math.round((normalized - 128) * 1.35 + 128))); + } pixels[i] = normalized; } @@ -353,34 +381,62 @@ function parseDicomPreview(filePath: string) { pixels: pixels.toString('base64'), windowCenter, windowWidth, + mode, }; } -function parseDicomPixels(filePath: string) { - const preview = parseDicomPreview(filePath); +function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') { + const preview = parseDicomPreview(filePath, mode); return { ...preview, pixelBuffer: Buffer.from(preview.pixels, 'base64'), }; } -function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal', slice: number) { - const first = parseDicomPixels(path.join(dicomDir, files[0])); - const maxSlice = plane === 'sagittal' ? first.width - 1 : first.height - 1; +function getDicomVolume(files: string[], mode: DicomDisplayMode) { + const cached = dicomVolumeCache.get(mode); + if (cached) { + return cached; + } + + const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode)); + const volume = { + frames: parsed.map((frame) => frame.pixelBuffer), + width: parsed[0]?.width ?? 0, + height: parsed[0]?.height ?? 0, + windowCenter: parsed[0]?.windowCenter ?? 40, + windowWidth: parsed[0]?.windowWidth ?? 400, + }; + dicomVolumeCache.set(mode, volume); + return volume; +} + +function warmDicomVolumeCache(files: string[]) { + setTimeout(() => { + try { + getDicomVolume(files, 'default'); + getDicomVolume(files, 'soft'); + } catch (error) { + console.warn('DICOM volume warmup failed:', error); + } + }, 300); +} + +function createReformattedPreview(files: string[], plane: Exclude, slice: number, mode: DicomDisplayMode) { + const volume = getDicomVolume(files, mode); + const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1; const clampedSlice = Math.max(0, Math.min(maxSlice, slice)); const outputWidth = files.length; - const outputHeight = plane === 'sagittal' ? first.height : first.width; + const outputHeight = plane === 'sagittal' ? volume.height : volume.width; const pixels = Buffer.alloc(outputWidth * outputHeight); - files.forEach((fileName, z) => { - const frame = parseDicomPixels(path.join(dicomDir, fileName)); - + volume.frames.forEach((frame, z) => { for (let row = 0; row < outputHeight; row += 1) { const sourceIndex = plane === 'sagittal' - ? row * frame.width + clampedSlice - : clampedSlice * frame.width + row; + ? row * volume.width + clampedSlice + : clampedSlice * volume.width + row; const targetIndex = row * outputWidth + z; - pixels[targetIndex] = frame.pixelBuffer[sourceIndex] ?? 0; + pixels[targetIndex] = frame[sourceIndex] ?? 0; } }); @@ -388,14 +444,65 @@ function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal' width: outputWidth, height: outputHeight, pixels: pixels.toString('base64'), - windowCenter: first.windowCenter, - windowWidth: first.windowWidth, + windowCenter: volume.windowCenter, + windowWidth: volume.windowWidth, slice: clampedSlice, total: maxSlice + 1, fileName: `${plane}-${clampedSlice}`, + mode, }; } +function createStlPreview(filePath: string, fileName: string, limit: number) { + const cacheKey = `${fileName}:${limit}`; + const cached = modelPreviewCache.get(cacheKey); + if (cached) { + return cached; + } + + const buffer = fs.readFileSync(filePath); + if (buffer.length < 84) { + throw new Error('STL 文件内容为空或不完整'); + } + + const triangleCount = buffer.readUInt32LE(80); + const expectedLength = 84 + triangleCount * 50; + if (triangleCount <= 0 || expectedLength > buffer.length + 1024) { + throw new Error('当前仅支持二进制 STL 预览'); + } + + const sampleLimit = Math.max(100, Math.min(limit, 12000)); + const step = Math.max(1, Math.ceil(triangleCount / sampleLimit)); + const vertices: number[] = []; + let sampledTriangles = 0; + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += step) { + const offset = 84 + triangleIndex * 50; + if (offset + 50 > buffer.length) { + break; + } + + for (let vertex = 0; vertex < 3; vertex += 1) { + const vertexOffset = offset + 12 + vertex * 12; + vertices.push( + Number(buffer.readFloatLE(vertexOffset).toFixed(3)), + Number(buffer.readFloatLE(vertexOffset + 4).toFixed(3)), + Number(buffer.readFloatLE(vertexOffset + 8).toFixed(3)), + ); + } + sampledTriangles += 1; + } + + const payload = { + fileName, + triangleCount, + sampledTriangles, + vertices, + }; + modelPreviewCache.set(cacheKey, payload); + return payload; +} + async function startServer() { const app = express(); const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; @@ -512,26 +619,37 @@ async function startServer() { } const requestedPlane = String(req.query.plane ?? 'axial'); - const plane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial'; + const plane: DicomPlane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial'; + const requestedMode = String(req.query.mode ?? 'default'); + const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'default'; const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10); + const cacheKey = `${project.id}:${plane}:${mode}:${Number.isFinite(requestedSlice) ? requestedSlice : 0}`; + if (dicomPreviewCache.has(cacheKey)) { + res.json(dicomPreviewCache.get(cacheKey)); + return; + } + try { + let payload: unknown; if (plane === 'axial') { const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); - const preview = parseDicomPreview(path.join(dicomDir, files[slice])); - res.json({ + const preview = parseDicomPreview(path.join(dicomDir, files[slice]), mode); + payload = { ...preview, plane, slice, total: files.length, fileName: files[slice], - }); - return; + }; + } else { + payload = { + ...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode), + plane, + }; } - res.json({ - ...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0), - plane, - }); + dicomPreviewCache.set(cacheKey, payload); + res.json(payload); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' }); } @@ -549,6 +667,23 @@ async function startServer() { res.sendFile(path.join(modelDir, fileName)); }); + app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => { + const project = findProject(readState(), req.params.projectId); + const fileName = path.basename(req.params.fileName); + const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10); + + if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) { + res.status(404).json({ message: '模型文件不存在' }); + return; + } + + try { + res.json(createStlPreview(path.join(modelDir, fileName), fileName, Number.isFinite(limit) ? limit : 5000)); + } catch (error) { + res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' }); + } + }); + app.get('/api/overview', (_req, res) => { const state = readState(); const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0); @@ -617,6 +752,7 @@ async function startServer() { app.listen(port, host, () => { console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`); + warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject())); }); } diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 8f23e43..3963063 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, Search, @@ -15,14 +15,12 @@ import { Trash2, Upload } from 'lucide-react'; -import { Canvas, useLoader } from '@react-three/fiber'; -import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei'; -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; import * as THREE from 'three'; import { DicomPreview, Project } from '../types'; import { api, downloadMask } from '../lib/api'; type Plane = 'axial' | 'sagittal' | 'coronal'; +type DisplayMode = DicomPreview['mode']; interface ModuleStyle { visible: boolean; @@ -30,23 +28,76 @@ interface ModuleStyle { opacity: number; } +interface ModelPreviewPayload { + fileName: string; + triangleCount: number; + sampledTriangles: number; + vertices: number[]; +} + const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; -function StlModel({ url, color, opacity }: { url: string; color: string; opacity: number }) { - const geometry = useLoader(STLLoader, url); +function drawFallbackModelPreview( + canvas: HTMLCanvasElement, + previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>, +) { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(Math.floor(rect.width), 1); + const height = Math.max(Math.floor(rect.height), 1); + canvas.width = width * window.devicePixelRatio; + canvas.height = height * window.devicePixelRatio; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; - return ( - - - - ); + const context = canvas.getContext('2d'); + if (!context) return; + context.scale(window.devicePixelRatio, window.devicePixelRatio); + context.fillStyle = '#f8fafc'; + context.fillRect(0, 0, width, height); + + const allPoints = previews.flatMap(({ payload }) => { + const points: Array<[number, number]> = []; + for (let index = 0; index < payload.vertices.length; index += 3) { + points.push([payload.vertices[index], payload.vertices[index + 1]]); + } + return points; + }); + + if (!allPoints.length) return; + + const xs = allPoints.map((point) => point[0]); + const ys = allPoints.map((point) => point[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const spanX = Math.max(maxX - minX, 1); + const spanY = Math.max(maxY - minY, 1); + const scale = Math.min((width * 0.78) / spanX, (height * 0.78) / spanY); + const offsetX = width / 2 - ((minX + maxX) / 2) * scale; + const offsetY = height / 2 + ((minY + maxY) / 2) * scale; + + previews.forEach(({ payload, style }) => { + context.globalAlpha = Math.max(0.12, Math.min(style.opacity, 1)); + context.fillStyle = style.color; + context.strokeStyle = style.color; + for (let index = 0; index < payload.vertices.length; index += 9) { + const x1 = payload.vertices[index] * scale + offsetX; + const y1 = -payload.vertices[index + 1] * scale + offsetY; + const x2 = payload.vertices[index + 3] * scale + offsetX; + const y2 = -payload.vertices[index + 4] * scale + offsetY; + const x3 = payload.vertices[index + 6] * scale + offsetX; + const y3 = -payload.vertices[index + 7] * scale + offsetY; + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.lineTo(x3, y3); + context.closePath(); + context.fill(); + context.stroke(); + } + }); + context.globalAlpha = 1; } function DicomCanvas({ preview }: { preview: DicomPreview }) { @@ -85,6 +136,210 @@ function DicomCanvas({ preview }: { preview: DicomPreview }) { ); } +function NativeStlViewer({ + projectId, + files, + styles, +}: { + projectId: string; + files: string[]; + styles: Record; +}) { + const containerRef = useRef(null); + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState('准备加载模型'); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const visibleFiles = files.filter((file) => styles[file]?.visible !== false); + container.innerHTML = ''; + setProgress(visibleFiles.length ? 5 : 0); + setStatus(visibleFiles.length ? '正在加载 STL 模型...' : '没有可显示的模型'); + + if (!visibleFiles.length) { + return; + } + + let disposed = false; + let animationId = 0; + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#f8fafc'); + const camera = new THREE.PerspectiveCamera(45, Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1), 0.1, 1000); + let renderer: THREE.WebGLRenderer | null = null; + try { + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + } catch { + const fallbackCanvas = document.createElement('canvas'); + fallbackCanvas.className = 'absolute inset-0 h-full w-full'; + container.appendChild(fallbackCanvas); + setStatus('WebGL 不可用,正在生成二维模型预览...'); + let fallbackPreviews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }> = []; + + Promise.allSettled( + visibleFiles.map((fileName) => + fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=3500`) + .then((response) => { + if (!response.ok) throw new Error('模型预览数据加载失败'); + return response.json() as Promise; + }) + .then((payload) => ({ + payload, + style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }, + })), + ), + ).then((results) => { + if (disposed) return; + const previews = results + .filter((result): result is PromiseFulfilledResult<{ payload: ModelPreviewPayload; style: ModuleStyle }> => result.status === 'fulfilled') + .map((result) => result.value); + fallbackPreviews = previews; + drawFallbackModelPreview(fallbackCanvas, previews); + setProgress(100); + setStatus(previews.length ? '二维模型预览已生成' : '模型预览加载失败'); + }); + + const handleFallbackResize = () => { + if (fallbackPreviews.length) { + drawFallbackModelPreview(fallbackCanvas, fallbackPreviews); + } + }; + window.addEventListener('resize', handleFallbackResize); + + return () => { + disposed = true; + window.removeEventListener('resize', handleFallbackResize); + container.innerHTML = ''; + }; + } + + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + 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, 6); + scene.add(keyLight); + const fillLight = new THREE.DirectionalLight(0x9cc4ff, 0.55); + fillLight.position.set(-4, 2, -3); + scene.add(fillLight); + + const group = new THREE.Group(); + scene.add(group); + let loaded = 0; + let failed = 0; + + visibleFiles.forEach((fileName) => { + fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=6000`) + .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 style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }; + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshStandardMaterial({ + color: style.color, + opacity: style.opacity, + transparent: style.opacity < 1, + roughness: 0.48, + metalness: 0.08, + side: THREE.DoubleSide, + }), + ); + group.add(mesh); + loaded += 1; + setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100)); + setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`); + + if (loaded + failed === visibleFiles.length) { + const box = new THREE.Box3().setFromObject(group); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + group.position.sub(center); + const maxSize = Math.max(size.x, size.y, size.z) || 1; + group.scale.setScalar(4 / maxSize); + camera.position.set(4.5, 3.5, 5); + camera.lookAt(0, 0, 0); + setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成'); + } + }) + .catch(() => { + if (disposed) return; + failed += 1; + setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100)); + setStatus(`有 ${failed} 个模型加载失败`); + }); + }); + + 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; + group.rotation.y += 0.004; + renderer.render(scene, camera); + animationId = window.requestAnimationFrame(animate); + }; + animate(); + + return () => { + disposed = true; + window.cancelAnimationFrame(animationId); + window.removeEventListener('resize', handleResize); + renderer.dispose(); + group.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(); + } + } + }); + container.innerHTML = ''; + }; + }, [projectId, files.join('|'), JSON.stringify(styles)]); + + return ( +
+
+ {progress < 100 && ( +
+
+ {status} + {progress}% +
+
+
+
+
+ )} + {progress >= 100 && ( +
+ {status} +
+ )} +
+ ); +} + export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) { const [search, setSearch] = useState(''); const [projects, setProjects] = useState([]); @@ -94,6 +349,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [sliceIndex, setSliceIndex] = useState(0); const [plane, setPlane] = useState('axial'); + const [displayMode, setDisplayMode] = useState('default'); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); const [dicomError, setDicomError] = useState(''); @@ -137,6 +393,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri { id: 'sagittal', label: '矢状面' }, { id: 'coronal', label: '冠状面' }, ]; + const displayModes: Array<{ id: DisplayMode; label: string }> = [ + { id: 'default', label: '默认' }, + { id: 'bone', label: '骨窗' }, + { id: 'soft', label: '软组织' }, + { id: 'contrast', label: '高对比' }, + ]; const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); useEffect(() => { @@ -160,7 +422,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri let cancelled = false; setDicomError(''); - api.getDicomPreview(selectedProject.id, sliceIndex, plane) + api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode) .then((preview) => { if (!cancelled) { setDicomPreview(preview); @@ -176,7 +438,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri return () => { cancelled = true; }; - }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, viewMode]); + }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]); const updateModuleStyle = (fileName: string, partial: Partial) => { setModuleStyles(prev => ({ @@ -268,11 +530,11 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
@@ -427,7 +689,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri key={option.id} onClick={() => { setPlane(option.id); - setSliceIndex(0); + setSliceIndex(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256); }} className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${ plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white' @@ -437,6 +699,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri ))}
+
+ {displayModes.map((mode) => ( + + ))} +

PATIENT ID: {selectedProject.id}_XYZ

SCAN DATE: {selectedProject.createTime}

@@ -450,7 +725,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri )}
- WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} + WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label} 第 {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} 张
@@ -478,43 +753,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{/* Left: 3D Visualization */}
- - - - - - {stlFiles.some((fileName) => moduleStyles[fileName]?.visible !== false) ? ( - -
- - {stlFiles.map((fileName) => { - const style = moduleStyles[fileName] ?? { visible: true, color: '#3b82f6', opacity: 0.72 }; - if (!style.visible) { - return null; - } - return ( - - ); - })} - -
-
- ) : ( - - - - - - - )} -
- -
+
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0}
diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 3dba01d..7e86436 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { motion } from 'motion/react'; import { Dices, @@ -14,21 +14,37 @@ import { Plus, Play } from 'lucide-react'; -import { Canvas } from '@react-three/fiber'; -import { OrbitControls, Stage, PerspectiveCamera, Grid } from '@react-three/drei'; -import { MaskMapping, Project } from '../types'; +import { DicomPreview, MaskMapping, Project } from '../types'; import { api, downloadMask } from '../lib/api'; -function InteractiveModel({ offset }: { offset: [number, number, number] }) { +function FusionDicomCanvas({ preview }: { preview: DicomPreview }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!canvas || !context) return; + + 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]); + return ( - - - - - - - - + ); } @@ -38,6 +54,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { 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 [exporting, setExporting] = useState(false); const [exportMessage, setExportMessage] = useState('准备就绪'); @@ -66,9 +83,25 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { }; useEffect(() => { - api.getProject(projectId).then(setProject).catch(() => setProject(null)); + 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(() => { + setProject(null); + setFusionPreview(null); + }); }, [projectId]); + useEffect(() => { + if (!project?.dicomCount) return; + const timer = window.setTimeout(() => { + api.getDicomPreview(project.id, slice, 'axial', 'soft').then(setFusionPreview).catch(() => setFusionPreview(null)); + }, 180); + return () => window.clearTimeout(timer); + }, [project?.id, slice]); + useEffect(() => { if (isRegistering && progress < 100) { const timer = setTimeout(() => setProgress(p => p + 2), 50); @@ -86,6 +119,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

{project ? `${project.name} · ${project.dicomPath} ↔ ${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}

+ {project && ( +
+ 当前项目:{project.name} + DICOM {project.dicomCount} + STL {project.modelCount ?? 0} +
+ )}
-
-
-
-
+
+
+ {fusionPreview ? ( + + ) : ( +
正在载入 DICOM...
+ )} +
+
+
+
+
- -
- - - - - - - +
+ DICOM 与 STL 已等比例归一化并中心对齐
@@ -149,6 +193,18 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { 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" /> +
+ 切片 + {slice + 1}/{project?.dicomCount ?? 0} +
+ setSlice(Number(e.target.value))} + className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500" + />
diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 4896e59..10273d8 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -50,8 +50,8 @@ export const api = { request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, { method: 'DELETE', }), - getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial') => - request(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}`), + 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}`), getUsers: () => request('/api/users'), resetDemo: () => request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index a743569..220788d 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -60,6 +60,7 @@ export interface DicomPreview { height: number; pixels: string; plane: 'axial' | 'sagittal' | 'coronal'; + mode: 'default' | 'bone' | 'soft' | 'contrast'; slice: number; total: number; fileName: string; diff --git a/工程分析/实现方案-2026-05-04-04-58-36.md b/工程分析/实现方案-2026-05-04-04-58-36.md new file mode 100644 index 0000000..3bff2f5 --- /dev/null +++ b/工程分析/实现方案-2026-05-04-04-58-36.md @@ -0,0 +1,76 @@ +# 实现方案 + +时间戳:2026-05-04-04-58-36 + +## 修改目标 + +修复项目列表按钮重叠;增强逆向工作区当前项目与融合视图;增加 DICOM 缓存和显示模式;重做 3D 模型渲染加载状态,避免 React Three Fiber 引入的 Three.js `Clock` 警告。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/测试方案-2026-05-04-04-58-36.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 项目列表布局。 + - 将收缩按钮从标题区右侧移到侧栏外侧中线位置。 + - 保留 `+` 在项目列表标题行内,避免重叠。 +2. DICOM 预览缓存与显示模式。 + - 后端增加内存缓存:按 project、plane、slice、mode 缓存灰度预览。 + - API 增加 `mode=default|bone|soft|contrast`。 + - 前端增加显示模式切换,并将 mode 传给 API。 +3. 3D 模型渲染。 + - 项目库中不再使用 React Three Fiber Canvas。 + - 后端增加 STL 二进制采样预览 API,避免浏览器一次解析 240MB 原始 STL。 + - 前端改用原生 Three.js 手动创建 renderer、camera、scene、geometry。 + - 显示加载进度条;WebGL 不可用时使用二维 canvas 模型预览兜底。 + - 使用自动包围盒归一化、居中、缩放,确保模型可见。 +4. 逆向工作区。 + - 拉取当前项目详情。 + - 顶部显示当前项目名、DICOM/STL 数量和路径。 + - 融合视图显示 DICOM canvas 背景,并叠加简化 STL/模型轮廓或模型投影效果,表达等比例缩放、中心对齐状态。 +5. 验证与部署。 + - `npm run lint` + - `npm run build` + - API smoke test + - headless Chrome 冒烟检查 + - 重启 `tmux` 会话部署到 `4000`。 + +## 数据流 + +DICOM: + +前端选择 plane/slice/mode -> 后端命中或生成缓存预览 -> 前端 canvas 显示。 + +3D: + +前端读取 STL 采样预览 API -> 后端返回抽样三角面顶点 -> 原生 Three.js 生成材质、居中缩放 -> 渲染;WebGL 不可用时绘制二维投影预览。 + +逆向融合: + +前端按当前项目获取 DICOM 预览和项目信息 -> canvas 绘制影像 -> HTML/SVG/Three 投影层叠加中心对齐模型轮廓。 + +## 兼容性与回滚方案 + +- DICOM preview API 兼容旧参数,不传 mode 时默认为 default。 +- 如果原生 Three.js 渲染异常,页面会使用二维 canvas 兜底预览,不影响项目库浏览。 +- 运行态缓存仅在进程内,不写入 Git。 + +## 预计文件变更 + +- 修改后端 DICOM preview API。 +- 修改项目库 3D 组件和 DICOM 控件。 +- 修改逆向工作区融合视图。 +- 更新工程分析文档和经验记录。 + +## 人工审核状态 + +本次用户明确要求无需人工二次确认。 + +状态:自动确认,继续执行。 diff --git a/工程分析/测试方案-2026-05-04-04-58-36.md b/工程分析/测试方案-2026-05-04-04-58-36.md new file mode 100644 index 0000000..d485141 --- /dev/null +++ b/工程分析/测试方案-2026-05-04-04-58-36.md @@ -0,0 +1,78 @@ +# 测试方案 + +时间戳:2026-05-04-04-58-36 + +## 测试目标 + +验证项目列表按钮不重叠、DICOM 三方向和显示模式可用、3D 模型有加载进度并可见、逆向工作区显示当前项目和融合视图,以及控制台不再出现 `THREE.Clock` 警告。 + +## 静态检查 + +- 检查项目列表标题区 `+` 与收缩按钮布局。 +- 检查 DICOM preview API 是否支持 `mode`。 +- 检查项目库是否使用原生 Three.js renderer 并显示加载进度。 +- 检查逆向工作区是否显示当前项目。 + +## 构建与类型检查 + +```bash +cd WebSite +npm run lint +npm run build +``` + +预期: + +- TypeScript 检查通过。 +- Vite 构建通过。 + +## API 验证 + +```bash +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0&mode=default' +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=128&mode=bone' +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=128&mode=contrast' +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/models/头部.stl/preview?limit=2000' +``` + +预期: + +- DICOM 均返回 `width`、`height`、`pixels`、`mode`。 +- STL 预览返回 `triangleCount`、`sampledTriangles`、`vertices`。 + +## 页面验证 + +- 项目列表标题区按钮不重叠。 +- DICOM 视图可切换多种显示模式。 +- 矢状面/冠状面滑动有图像变化。 +- 3D 视图显示加载进度条,加载后模型可见。 +- 逆向工作区显示当前项目,融合视图显示 DICOM 与模型中心对齐叠加效果。 + +## 控制台验证 + +- headless Chrome 打开页面后不捕获 `THREE.Clock`。 +- 不捕获 `Uncaught`、`Error`。 + +## 实际执行结果 + +执行时间:2026-05-04 + +- `npm run lint`:通过。 +- `npm run build`:通过,仅保留 Vite chunk size 提示。 +- DICOM API: + - axial default:`512x512 150/300 WW=360 WL=60` + - sagittal bone:`300x512 128/512 WW=2000 WL=500` + - coronal contrast:`300x512 256/512 WW=180 WL=80` + - axial soft:`512x512 150/300 WW=400 WL=40` +- STL 预览 API:`头部.stl 2571248 2000 18000`。 +- Headless Chrome 自动化: + - 项目库进入成功。 + - 3D 模型页进入成功,模型加载/二维兜底状态可见。 + - 逆向工作区进入成功,当前项目与融合说明可见。 + - `THREE.Clock`、`non-passive`、`Uncaught` 捕获数为 0。 + +## 人工审核状态 + +本次用户明确要求无需人工二次确认。 + +状态:自动确认,继续执行。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 9402e77..3bec884 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -325,3 +325,75 @@ C. 解决问题方案 D. 后续如何避免问题 列表页面应减少常驻表单噪声;破坏性操作必须二次确认;轻量编辑可采用失焦保存,但需要避免空名称提交。 + +## 2026-05-04-04-58-36 项目列表按钮布局 + +A. 具体问题 + +项目列表标题旁的 `+` 创建按钮和侧栏收缩按钮靠得太近,视觉上发生重叠。 + +B. 产生问题原因 + +两个操作都放在项目列表标题区右侧,且侧栏宽度固定,未给低频收缩操作独立位置。 + +C. 解决问题方案 + +保留 `+` 在标题区,收缩按钮移动到项目列表侧栏中线外侧,并调整容器为 `overflow-visible`,避免按钮被卡片裁剪。 + +D. 后续如何避免问题 + +同一区域的高频操作和布局控制应分区放置;绝对定位按钮如果超出容器,要同步检查父容器裁剪策略。 + +## 2026-05-04-04-58-36 DICOM 三方向缓存与显示模式 + +A. 具体问题 + +矢状面和冠状面切换后图像变化慢或不明显,且 DICOM 只能用单一窗宽窗位显示。 + +B. 产生问题原因 + +每次重建非横断面都要重新读取 DICOM 序列,前后端没有把显示模式作为预览参数,也没有复用已解析的体数据。 + +C. 解决问题方案 + +后端按显示模式缓存 DICOM 体数据和预览结果,API 增加 `mode=default|bone|soft|contrast`;前端切换方向时重置到对应方向中间层,并提供显示模式分段按钮。 + +D. 后续如何避免问题 + +DICOM 多平面重建应优先设计缓存键和窗宽窗位参数;后续接入真实医学影像库时继续保留 plane、slice、mode 的稳定 API 契约。 + +## 2026-05-04-04-58-36 STL 大文件预览 + +A. 具体问题 + +3D 模型页容易空白,直接加载 9 个原始 STL 总量约 240MB,浏览器解析慢且缺少可靠进度反馈。 + +B. 产生问题原因 + +前端承担了原始 STL 解析和渲染的全部工作,大体积二进制模型会阻塞交互并放大 WebGL 环境差异。 + +C. 解决问题方案 + +后端新增 STL 二进制采样预览 API,只返回抽样三角面顶点;前端用原生 Three.js 按采样顶点生成 BufferGeometry,并显示加载进度。WebGL 不可用时改用二维 canvas 投影预览兜底。 + +D. 后续如何避免问题 + +大模型浏览应区分“预览网格”和“原始精度文件”;列表/项目库优先加载轻量预览,进入精修或导出阶段再读取完整 STL。 + +## 2026-05-04-04-58-36 逆向工作区项目上下文与融合视图 + +A. 具体问题 + +逆向工作区没有明确显示当前项目,融合视图没有展示 DICOM 与 STL 归一化中心对齐的效果。 + +B. 产生问题原因 + +工作区初版更多是流程面板,缺少从项目库传入项目后继续呈现项目上下文和影像/模型叠加结果的状态。 + +C. 解决问题方案 + +工作区进入后读取当前项目详情,顶部显示项目名、DICOM 数量、STL 数量和路径;融合视图加载 DICOM 软组织窗切片,并叠加中心对齐的模型轮廓、十字参考线和切片滑块。 + +D. 后续如何避免问题 + +跨页面工作流必须在目标页面重新显示当前操作对象;医学影像配准类视图至少应具备影像层、模型层、对齐标识和当前切片控制。 diff --git a/工程分析/需求分析-2026-05-04-04-58-36.md b/工程分析/需求分析-2026-05-04-04-58-36.md new file mode 100644 index 0000000..2f9bfdd --- /dev/null +++ b/工程分析/需求分析-2026-05-04-04-58-36.md @@ -0,0 +1,61 @@ +# 需求分析 + +时间戳:2026-05-04-04-58-36 + +## 原始需求摘要 + +用户要求严格使用代码编纂工作流处理本次修改,并在开始时确认工作流整体流程;本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。 + +具体需求: + +1. 项目列表旁边的 `+` 和向内收缩按钮重叠。 +2. 逆向工作区需要显示当前项目;影像与模型融合视角应显示 DICOM 与 STL 等比例拉伸到同种形状、中心对齐后的效果。 +3. 项目列表中矢状面和冠状面看起来不动;应在最早创建/载入项目时把图像处理并预存;同时 DICOM 应支持多种显示模式。 +4. 3D 模型页面为空;如果正在加载,应显示加载进度条;如果当前方法不可行,可调用 Python 现成包。 +5. 控制台出现 `THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.` 和 OrbitControls 非 passive wheel 事件警告。 + +## 业务目标 + +- 优化项目库左侧标题区布局,避免操作按钮重叠。 +- 让逆向工作区明确显示当前项目,并提供直观的 DICOM/STL 融合对齐预览。 +- 提升 DICOM 三方向浏览性能和可用性,支持缓存和显示模式。 +- 确保 3D 模型预览可见,并提供清晰的加载状态。 +- 尽量消除由 React Three Fiber / Drei 引入的 Three.js 弃用警告。 + +## 输入与输出 + +输入: + +- 当前项目 ID。 +- 默认 DICOM 序列与 STL 文件。 +- 用户选择 DICOM 平面、切片与显示模式。 +- 用户调整 STL 显示颜色、透明度和可见性。 + +输出: + +- 项目列表标题区不重叠。 +- 逆向工作区顶部和内容区显示当前项目。 +- 融合视图显示 DICOM 背景和 STL 归一化叠加效果。 +- DICOM 三方向预览来自后端缓存,支持窗宽窗位、骨窗、软组织、高对比模式。 +- 3D 模型显示加载进度,模型加载完成后可见。 +- 控制台不再出现 `THREE.Clock` 警告。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/经验记录.md` + +## 风险点 + +- DICOM 三方向缓存会增加运行态内存和首次请求计算量。 +- 用原生 Three.js 替换 React Three Fiber 可减少依赖警告,但需要手动管理 renderer/camera/geometry 生命周期。 +- 当前融合视图仍是演示级对齐,不等同真实医学配准矩阵。 +- Python 本次暂不引入,因为 Node/Three.js 能完成本次显示和缓存目标;后续真实体素化再引入更合理。 + +## 待确认问题 + +- 本次用户已明确无需二次确认,直接执行。