2026-05-04-04-58-36 优化DICOM缓存和三维融合预览

This commit is contained in:
2026-05-04 05:15:59 +08:00
parent 4aad0f815d
commit 4ef3be69f4
9 changed files with 837 additions and 118 deletions

View File

@@ -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 (
<mesh geometry={geometry as THREE.BufferGeometry}>
<meshStandardMaterial
color={color}
opacity={opacity}
transparent={opacity < 1}
roughness={0.48}
metalness={0.08}
side={THREE.DoubleSide}
/>
</mesh>
);
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<string, ModuleStyle>;
}) {
const containerRef = useRef<HTMLDivElement | null>(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<ModelPreviewPayload>;
})
.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<ModelPreviewPayload>;
})
.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 (
<div className="h-full w-full relative">
<div ref={containerRef} className="absolute inset-0" />
{progress < 100 && (
<div className="absolute inset-x-8 top-8 z-10 rounded-xl bg-white/90 p-4 shadow-sm border border-slate-100">
<div className="flex items-center justify-between text-xs font-bold text-slate-600 mb-2">
<span>{status}</span>
<span>{progress}%</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-blue-600 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{progress >= 100 && (
<div className="absolute left-4 top-4 rounded-lg bg-white/80 px-3 py-1.5 text-[10px] font-bold text-slate-500 shadow-sm">
{status}
</div>
)}
</div>
);
}
export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) {
const [search, setSearch] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
@@ -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<Plane>('axial');
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(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<ModuleStyle>) => {
setModuleStyles(prev => ({
@@ -268,11 +530,11 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div
className={`${
isSidebarCollapsed ? 'w-12' : 'w-72'
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-hidden shrink-0`}
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-visible shrink-0`}
>
<button
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
className="absolute right-1 top-4 z-10 p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 transition-colors"
className="absolute -right-3 top-1/2 z-10 h-7 w-7 -translate-y-1/2 bg-white border border-slate-100 shadow-md hover:bg-slate-50 rounded-full text-slate-400 transition-colors flex items-center justify-center"
>
{isSidebarCollapsed ? <ChevronRight size={18} /> : <ChevronRight className="rotate-180" size={18} />}
</button>
@@ -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
</button>
))}
</div>
<div className="absolute top-16 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setDisplayMode(mode.id)}
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
displayMode === mode.id ? 'bg-emerald-600 text-white' : 'text-white/50 hover:text-white'
}`}
>
{mode.label}
</button>
))}
</div>
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
<p>SCAN DATE: {selectedProject.createTime}</p>
@@ -450,7 +725,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
)}
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40}</span>
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
<span> {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
</div>
</div>
@@ -478,43 +753,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="h-full flex gap-8">
{/* Left: 3D Visualization */}
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
<Canvas>
<color attach="background" args={['#f8fafc']} />
<ambientLight intensity={0.65} />
<directionalLight position={[3, 6, 5]} intensity={1.1} />
<Suspense fallback={null}>
{stlFiles.some((fileName) => moduleStyles[fileName]?.visible !== false) ? (
<Bounds fit clip observe margin={1.25}>
<Center>
<group>
{stlFiles.map((fileName) => {
const style = moduleStyles[fileName] ?? { visible: true, color: '#3b82f6', opacity: 0.72 };
if (!style.visible) {
return null;
}
return (
<StlModel
key={fileName}
url={`/api/projects/${selectedProject.id}/models/${encodeURIComponent(fileName)}`}
color={style.color}
opacity={style.opacity}
/>
);
})}
</group>
</Center>
</Bounds>
) : (
<Stage environment="city" intensity={0.5}>
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#94a3b8" />
</mesh>
</Stage>
)}
</Suspense>
<OrbitControls />
</Canvas>
<NativeStlViewer projectId={selectedProject.id} files={stlFiles} styles={moduleStyles} />
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0}
</div>