2026-05-04-04-58-36 优化DICOM缓存和三维融合预览
This commit is contained in:
@@ -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()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
76
工程分析/实现方案-2026-05-04-04-58-36.md
Normal file
76
工程分析/实现方案-2026-05-04-04-58-36.md
Normal file
@@ -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 控件。
|
||||
- 修改逆向工作区融合视图。
|
||||
- 更新工程分析文档和经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
78
工程分析/测试方案-2026-05-04-04-58-36.md
Normal file
78
工程分析/测试方案-2026-05-04-04-58-36.md
Normal file
@@ -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。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
72
工程分析/经验记录.md
72
工程分析/经验记录.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. 后续如何避免问题
|
||||
|
||||
跨页面工作流必须在目标页面重新显示当前操作对象;医学影像配准类视图至少应具备影像层、模型层、对齐标识和当前切片控制。
|
||||
|
||||
61
工程分析/需求分析-2026-05-04-04-58-36.md
Normal file
61
工程分析/需求分析-2026-05-04-04-58-36.md
Normal file
@@ -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 能完成本次显示和缓存目标;后续真实体素化再引入更合理。
|
||||
|
||||
## 待确认问题
|
||||
|
||||
- 本次用户已明确无需二次确认,直接执行。
|
||||
Reference in New Issue
Block a user