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

@@ -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<string, unknown>();
const dicomVolumeCache = new Map<DicomDisplayMode, {
frames: Buffer[];
width: number;
height: number;
windowCenter: number;
windowWidth: number;
}>();
const modelPreviewCache = new Map<string, unknown>();
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<DicomPlane, 'axial'>, 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()));
});
}

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>

View File

@@ -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<HTMLCanvasElement | null>(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 (
<mesh position={offset}>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#3b82f6" transparent opacity={0.6} />
<mesh position={[0, 0, 0]}>
<boxGeometry args={[2.05, 2.05, 2.05]} />
<meshBasicMaterial color="#ffffff" wireframe />
</mesh>
</mesh>
<canvas
ref={canvasRef}
width={preview.width}
height={preview.height}
className="absolute inset-0 h-full w-full object-contain opacity-80"
/>
);
}
@@ -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<Project | null>(null);
const [fusionPreview, setFusionPreview] = useState<DicomPreview | null>(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 }) {
<p className="text-slate-500 mt-1">
{project ? `${project.name} · ${project.dicomPath}${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
</p>
{project && (
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
<span className="rounded-lg bg-blue-50 px-3 py-1 text-blue-700">{project.name}</span>
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">DICOM {project.dicomCount}</span>
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">STL {project.modelCount ?? 0}</span>
</div>
)}
</div>
<div className="flex gap-2">
<button
@@ -117,24 +157,28 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<Rotate3d size={18} className="text-blue-500" />
</h3>
<span className="text-[10px] font-mono text-slate-400">Layer: {slice}</span>
<span className="text-[10px] font-mono text-slate-400">Layer: {slice + 1}/{project?.dicomCount ?? 0}</span>
</div>
<div className="flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group">
<div className="absolute inset-0 z-0 opacity-40">
<div className="w-full h-full flex items-center justify-center p-12">
<div className="w-full h-full border-2 border-white/5 rounded-full flex items-center justify-center anonymous-dicom-grid" />
</div>
<div className="absolute inset-0 z-0 flex items-center justify-center p-8">
<div className="relative aspect-square w-full max-w-[460px] overflow-hidden rounded-2xl border border-white/10 bg-black">
{fusionPreview ? (
<FusionDicomCanvas preview={fusionPreview} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-white/40"> DICOM...</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 inset-0 z-10">
<Canvas>
<PerspectiveCamera makeDefault position={[3, 3, 3]} />
<Stage environment="city" intensity={0.5}>
<InteractiveModel offset={offset} />
</Stage>
<OrbitControls />
</Canvas>
<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">
@@ -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"
/>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-white uppercase opacity-60"></span>
<span className="text-[9px] text-blue-300">{slice + 1}/{project?.dicomCount ?? 0}</span>
</div>
<input
type="range"
min="0"
max={Math.max((project?.dicomCount ?? 1) - 1, 0)}
value={slice}
onChange={(e) => setSlice(Number(e.target.value))}
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
/>
</div>
</div>
</div>

View File

@@ -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<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}`),
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}`),
getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {

View File

@@ -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;