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>
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user