2026-05-07-16-20-46 修正DICOM比例和3D默认位姿

This commit is contained in:
2026-05-07 16:26:57 +08:00
parent 1cc750b7e4
commit aa0d51316e
11 changed files with 1012 additions and 87 deletions

View File

@@ -63,9 +63,37 @@ const dicomVolumeCache = new Map<DicomDisplayMode, {
height: number;
windowCenter: number;
windowWidth: number;
rowSpacing: number;
columnSpacing: number;
sliceSpacing: number;
sliceThickness: number | null;
spacingBetweenSlices: number | null;
}>();
const modelPreviewCache = new Map<string, unknown>();
interface DicomAttributes {
patientName: string;
patientId: string;
studyDate: string;
studyDescription: string;
seriesDescription: string;
modality: string;
manufacturer: string;
rows: number;
columns: number;
bitsAllocated: number;
pixelRepresentation: number;
windowCenter: number;
windowWidth: number;
rescaleIntercept: number;
rescaleSlope: number;
rowSpacing: number;
columnSpacing: number;
sliceThickness: number | null;
spacingBetweenSlices: number | null;
imagePosition: number[] | null;
}
function today() {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date());
}
@@ -290,6 +318,64 @@ function readAsciiValue(buffer: Buffer, start: number, length: number) {
return buffer.subarray(start, start + length).toString('ascii').replace(/\0/g, '').trim();
}
function readTagString(buffer: Buffer, group: number, element: number) {
const tag = findExplicitTag(buffer, group, element);
return tag ? readAsciiValue(buffer, tag.valueOffset, tag.length) : '';
}
function readTagUInt16(buffer: Buffer, group: number, element: number, fallback = 0) {
const tag = findExplicitTag(buffer, group, element);
return tag && tag.valueOffset + 1 < buffer.length ? buffer.readUInt16LE(tag.valueOffset) : fallback;
}
function parseNumberList(value: string) {
return value
.split('\\')
.map((item) => Number.parseFloat(item.trim()))
.filter((item) => Number.isFinite(item));
}
function median(values: number[]) {
if (!values.length) {
return null;
}
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function parseDicomAttributes(buffer: Buffer, mode: DicomDisplayMode): DicomAttributes {
const fallbackCenter = Number.parseFloat(readTagString(buffer, 0x0028, 0x1050).split('\\')[0]) || 40;
const fallbackWidth = Number.parseFloat(readTagString(buffer, 0x0028, 0x1051).split('\\')[0]) || 400;
const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth);
const pixelSpacing = parseNumberList(readTagString(buffer, 0x0028, 0x0030));
const imagePosition = parseNumberList(readTagString(buffer, 0x0020, 0x0032));
const sliceThickness = Number.parseFloat(readTagString(buffer, 0x0018, 0x0050));
const spacingBetweenSlices = Number.parseFloat(readTagString(buffer, 0x0018, 0x0088));
return {
patientName: readTagString(buffer, 0x0010, 0x0010) || '未知',
patientId: readTagString(buffer, 0x0010, 0x0020) || '未知',
studyDate: readTagString(buffer, 0x0008, 0x0020) || '未知',
studyDescription: readTagString(buffer, 0x0008, 0x1030) || '未知',
seriesDescription: readTagString(buffer, 0x0008, 0x103e) || '未知',
modality: readTagString(buffer, 0x0008, 0x0060) || '未知',
manufacturer: readTagString(buffer, 0x0008, 0x0070) || '未知',
rows: readTagUInt16(buffer, 0x0028, 0x0010),
columns: readTagUInt16(buffer, 0x0028, 0x0011),
bitsAllocated: readTagUInt16(buffer, 0x0028, 0x0100, 16),
pixelRepresentation: readTagUInt16(buffer, 0x0028, 0x0103),
windowCenter,
windowWidth,
rescaleIntercept: Number.parseFloat(readTagString(buffer, 0x0028, 0x1052)) || 0,
rescaleSlope: Number.parseFloat(readTagString(buffer, 0x0028, 0x1053)) || 1,
rowSpacing: pixelSpacing[0] || 1,
columnSpacing: pixelSpacing[1] || pixelSpacing[0] || 1,
sliceThickness: Number.isFinite(sliceThickness) ? Math.abs(sliceThickness) : null,
spacingBetweenSlices: Number.isFinite(spacingBetweenSlices) ? Math.abs(spacingBetweenSlices) : null,
imagePosition: imagePosition.length >= 3 ? imagePosition.slice(0, 3) : null,
};
}
function findExplicitTag(buffer: Buffer, group: number, element: number) {
const pattern = Buffer.from([
group & 0xff,
@@ -332,46 +418,29 @@ function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fa
function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') {
const buffer = fs.readFileSync(filePath);
const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010);
const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011);
const bitsTag = findExplicitTag(buffer, 0x0028, 0x0100);
const representationTag = findExplicitTag(buffer, 0x0028, 0x0103);
const centerTag = findExplicitTag(buffer, 0x0028, 0x1050);
const widthTag = findExplicitTag(buffer, 0x0028, 0x1051);
const interceptTag = findExplicitTag(buffer, 0x0028, 0x1052);
const slopeTag = findExplicitTag(buffer, 0x0028, 0x1053);
const attrs = parseDicomAttributes(buffer, mode);
const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
const rows = rowsTag ? buffer.readUInt16LE(rowsTag.valueOffset) : 0;
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 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;
const pixelLength = pixelTag?.length ?? 0;
if (!rows || !columns || pixelOffset < 0) {
if (!attrs.rows || !attrs.columns || pixelOffset < 0) {
throw new Error('无法解析当前 DICOM 像素数据');
}
const count = rows * columns;
const count = attrs.rows * attrs.columns;
const pixels = Buffer.alloc(count);
const min = windowCenter - windowWidth / 2;
const max = windowCenter + windowWidth / 2;
const min = attrs.windowCenter - attrs.windowWidth / 2;
const max = attrs.windowCenter + attrs.windowWidth / 2;
for (let i = 0; i < count; i += 1) {
const position = pixelOffset + i * (bitsAllocated / 8);
const position = pixelOffset + i * (attrs.bitsAllocated / 8);
if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) {
break;
}
const raw = bitsAllocated === 16
? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
const raw = attrs.bitsAllocated === 16
? (attrs.pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
: buffer.readUInt8(position);
const hu = raw * rescaleSlope + rescaleIntercept;
const hu = raw * attrs.rescaleSlope + attrs.rescaleIntercept;
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)));
@@ -379,15 +448,25 @@ function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default')
pixels[i] = normalized;
}
const enhancedPixels = enhanceDicomEdges(pixels, columns, rows);
const enhancedPixels = enhanceDicomEdges(pixels, attrs.columns, attrs.rows);
return {
width: columns,
height: rows,
width: attrs.columns,
height: attrs.rows,
pixels: enhancedPixels.toString('base64'),
windowCenter,
windowWidth,
windowCenter: attrs.windowCenter,
windowWidth: attrs.windowWidth,
mode,
spacing: {
row: attrs.rowSpacing,
column: attrs.columnSpacing,
slice: attrs.sliceThickness ?? attrs.spacingBetweenSlices ?? 1,
},
physicalSize: {
width: attrs.columns * attrs.columnSpacing,
height: attrs.rows * attrs.rowSpacing,
},
attributes: attrs,
};
}
@@ -399,6 +478,28 @@ function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default')
};
}
function estimateSliceSpacing(parsed: ReturnType<typeof parseDicomPixels>[]) {
const positionDiffs: number[] = [];
for (let index = 1; index < parsed.length; index += 1) {
const previous = parsed[index - 1].attributes.imagePosition;
const current = parsed[index].attributes.imagePosition;
if (previous && current) {
const dx = current[0] - previous[0];
const dy = current[1] - previous[1];
const dz = current[2] - previous[2];
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (distance > 0.0001) {
positionDiffs.push(distance);
}
}
}
return median(positionDiffs)
?? parsed[0]?.attributes.spacingBetweenSlices
?? parsed[0]?.attributes.sliceThickness
?? 1;
}
function getDicomVolume(files: string[], mode: DicomDisplayMode) {
const cached = dicomVolumeCache.get(mode);
if (cached) {
@@ -406,17 +507,59 @@ function getDicomVolume(files: string[], mode: DicomDisplayMode) {
}
const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode));
const sliceSpacing = estimateSliceSpacing(parsed);
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,
rowSpacing: parsed[0]?.attributes.rowSpacing ?? 1,
columnSpacing: parsed[0]?.attributes.columnSpacing ?? 1,
sliceSpacing,
sliceThickness: parsed[0]?.attributes.sliceThickness ?? null,
spacingBetweenSlices: parsed[0]?.attributes.spacingBetweenSlices ?? null,
};
dicomVolumeCache.set(mode, volume);
return volume;
}
function resampleNearest(pixels: Buffer, width: number, height: number, targetWidth: number, targetHeight: number) {
if (width === targetWidth && height === targetHeight) {
return pixels;
}
const output = Buffer.alloc(targetWidth * targetHeight);
for (let y = 0; y < targetHeight; y += 1) {
const sourceY = Math.min(height - 1, Math.floor((y / targetHeight) * height));
for (let x = 0; x < targetWidth; x += 1) {
const sourceX = Math.min(width - 1, Math.floor((x / targetWidth) * width));
output[y * targetWidth + x] = pixels[sourceY * width + sourceX];
}
}
return output;
}
function resampleToPhysicalAspect(pixels: Buffer, width: number, height: number, xSpacing: number, ySpacing: number) {
const physicalWidth = width * xSpacing;
const physicalHeight = height * ySpacing;
const unit = Math.max(0.001, Math.min(xSpacing, ySpacing));
let targetWidth = Math.max(1, Math.round(physicalWidth / unit));
let targetHeight = Math.max(1, Math.round(physicalHeight / unit));
const maxDimension = 960;
const scale = Math.min(1, maxDimension / Math.max(targetWidth, targetHeight));
targetWidth = Math.max(1, Math.round(targetWidth * scale));
targetHeight = Math.max(1, Math.round(targetHeight * scale));
return {
width: targetWidth,
height: targetHeight,
pixels: resampleNearest(pixels, width, height, targetWidth, targetHeight),
physicalWidth,
physicalHeight,
};
}
function warmDicomVolumeCache(files: string[]) {
setTimeout(() => {
try {
@@ -447,11 +590,13 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
});
const cropped = cropDicomContent(pixels, outputWidth, outputHeight);
const enhancedPixels = enhanceDicomEdges(cropped.pixels, cropped.width, cropped.height);
const ySpacing = plane === 'sagittal' ? volume.rowSpacing : volume.columnSpacing;
const physical = resampleToPhysicalAspect(cropped.pixels, cropped.width, cropped.height, volume.sliceSpacing, ySpacing);
const enhancedPixels = enhanceDicomEdges(physical.pixels, physical.width, physical.height);
return {
width: cropped.width,
height: cropped.height,
width: physical.width,
height: physical.height,
pixels: enhancedPixels.toString('base64'),
windowCenter: volume.windowCenter,
windowWidth: volume.windowWidth,
@@ -459,6 +604,17 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
total: maxSlice + 1,
fileName: `${plane}-${clampedSlice}`,
mode,
spacing: {
row: volume.rowSpacing,
column: volume.columnSpacing,
slice: volume.sliceSpacing,
displayX: volume.sliceSpacing,
displayY: ySpacing,
},
physicalSize: {
width: physical.physicalWidth,
height: physical.physicalHeight,
},
};
}
@@ -545,7 +701,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
throw new Error('当前仅支持二进制 STL 预览');
}
const sampleLimit = Math.max(100, Math.min(limit, 36000));
const sampleLimit = Math.max(100, Math.min(limit, 72000));
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = [];
let sampledTriangles = 0;
@@ -623,6 +779,100 @@ function createDicomTarGz(files: string[]) {
return zlib.gzipSync(Buffer.concat(chunks));
}
function estimateSliceSpacingFromAttributes(attributes: DicomAttributes[]) {
const diffs: number[] = [];
for (let index = 1; index < attributes.length; index += 1) {
const previous = attributes[index - 1].imagePosition;
const current = attributes[index].imagePosition;
if (previous && current) {
const dx = current[0] - previous[0];
const dy = current[1] - previous[1];
const dz = current[2] - previous[2];
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (distance > 0.0001) {
diffs.push(distance);
}
}
}
return {
value: median(diffs)
?? attributes[0]?.spacingBetweenSlices
?? attributes[0]?.sliceThickness
?? 1,
source: diffs.length ? 'ImagePositionPatient' : attributes[0]?.spacingBetweenSlices ? 'SpacingBetweenSlices' : attributes[0]?.sliceThickness ? 'SliceThickness' : '默认 1mm',
};
}
function formatNumber(value: number | null | undefined, digits = 3) {
return typeof value === 'number' && Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
}
function createDicomInfo(project: ProjectRecord, files: string[]) {
const attributes = files.map((fileName) => {
const buffer = fs.readFileSync(path.join(dicomDir, fileName));
return parseDicomAttributes(buffer, 'default');
});
const first = attributes[0];
const last = attributes[attributes.length - 1];
const sliceSpacing = estimateSliceSpacingFromAttributes(attributes);
const physicalWidth = first.columns * first.columnSpacing;
const physicalHeight = first.rows * first.rowSpacing;
const physicalDepth = Math.max(files.length - 1, 0) * sliceSpacing.value;
return {
project: {
id: project.id,
name: project.name,
dicomPath: project.dicomPath,
},
patient: {
name: first.patientName,
id: first.patientId,
},
study: {
date: first.studyDate,
description: first.studyDescription,
modality: first.modality,
manufacturer: first.manufacturer,
},
series: {
description: first.seriesDescription,
files: files.length,
firstFile: files[0] ?? '',
lastFile: files[files.length - 1] ?? '',
},
image: {
rows: first.rows,
columns: first.columns,
bitsAllocated: first.bitsAllocated,
pixelRepresentation: first.pixelRepresentation,
windowCenter: first.windowCenter,
windowWidth: first.windowWidth,
rescaleIntercept: first.rescaleIntercept,
rescaleSlope: first.rescaleSlope,
},
spacing: {
row: formatNumber(first.rowSpacing),
column: formatNumber(first.columnSpacing),
slice: formatNumber(sliceSpacing.value),
sliceSource: sliceSpacing.source,
sliceThickness: formatNumber(first.sliceThickness),
spacingBetweenSlices: formatNumber(first.spacingBetweenSlices),
},
physicalSize: {
width: formatNumber(physicalWidth),
height: formatNumber(physicalHeight),
depth: formatNumber(physicalDepth),
unit: 'mm',
},
position: {
firstImagePosition: first.imagePosition,
lastImagePosition: last?.imagePosition ?? null,
},
};
}
async function startServer() {
const app = express();
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
@@ -799,6 +1049,26 @@ async function startServer() {
}
});
app.get('/api/projects/:projectId/dicom-info', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const files = getProjectDicomFiles(project);
if (!files.length) {
res.status(404).json({ message: '当前项目没有可查询的 DICOM 文件' });
return;
}
try {
res.json(createDicomInfo(project, files));
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 信息解析失败' });
}
});
app.get('/api/projects/:projectId/models/:fileName', (req, res) => {
const project = findProject(readState(), req.params.projectId);
const fileName = path.basename(req.params.fileName);

View File

@@ -8,6 +8,7 @@ import {
RotateCcw,
Box,
Image as ImageIcon,
Info,
ChevronRight,
ChevronUp,
ChevronDown,
@@ -20,12 +21,12 @@ import {
Upload
} from 'lucide-react';
import * as THREE from 'three';
import { DicomPreview, Project } from '../types';
import { DicomInfo, DicomPreview, Project } from '../types';
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
type SolidityLevel = 'preview' | 'standard' | 'fine';
type SolidityLevel = 'preview' | 'standard' | 'fine' | 'ultra';
interface ModuleStyle {
visible: boolean;
@@ -41,7 +42,6 @@ interface ModelPose {
translateY: number;
translateZ: number;
scale: number;
autoRotate: boolean;
}
interface ModelPreviewPayload {
@@ -56,6 +56,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }
{ id: 'preview', label: '预览', limit: 6000 },
{ id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 },
{ id: 'ultra', label: '超精细', limit: 72000 },
];
const defaultModelPose: ModelPose = {
rotateX: 0,
@@ -65,7 +66,6 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
autoRotate: true,
};
function drawFallbackModelPreview(
@@ -181,6 +181,13 @@ function safeFilePart(value: string) {
return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom';
}
function displayDicomValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') {
return '未知';
}
return String(value);
}
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -205,18 +212,19 @@ function NativeStlViewer({
files,
styles,
detailLimit,
solidWhite,
pose,
onPoseChange,
}: {
projectId: string;
files: string[];
styles: Record<string, ModuleStyle>;
detailLimit: number;
solidWhite: boolean;
pose: ModelPose;
onPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const poseRef = useRef<ModelPose>(pose);
const onPoseChangeRef = useRef(onPoseChange);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('准备加载模型');
@@ -224,6 +232,92 @@ function NativeStlViewer({
poseRef.current = pose;
}, [pose]);
useEffect(() => {
onPoseChangeRef.current = onPoseChange;
}, [onPoseChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const clampPose = (next: ModelPose): ModelPose => ({
rotateX: Math.max(-180, Math.min(180, next.rotateX)),
rotateY: Math.max(-180, Math.min(180, next.rotateY)),
rotateZ: Math.max(-180, Math.min(180, next.rotateZ)),
translateX: Math.max(-2, Math.min(2, next.translateX)),
translateY: Math.max(-2, Math.min(2, next.translateY)),
translateZ: Math.max(-2, Math.min(2, next.translateZ)),
scale: Math.max(0.5, Math.min(2.5, next.scale)),
});
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
pointerId: 0,
startX: 0,
startY: 0,
startPose: poseRef.current,
};
const handlePointerDown = (event: PointerEvent) => {
dragState.active = true;
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
dragState.pointerId = event.pointerId;
dragState.startX = event.clientX;
dragState.startY = event.clientY;
dragState.startPose = poseRef.current;
container.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (dragState.mode === 'pan') {
onPoseChangeRef.current(clampPose({
...dragState.startPose,
translateX: dragState.startPose.translateX + deltaX * 0.006,
translateY: dragState.startPose.translateY - deltaY * 0.006,
}));
return;
}
onPoseChangeRef.current(clampPose({
...dragState.startPose,
rotateY: dragState.startPose.rotateY + deltaX * 0.35,
rotateX: dragState.startPose.rotateX + deltaY * 0.35,
}));
};
const stopPointerDrag = (event: PointerEvent) => {
if (event.pointerId !== dragState.pointerId) return;
dragState.active = false;
if (container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
};
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
onPoseChangeRef.current(clampPose({
...poseRef.current,
scale: poseRef.current.scale - event.deltaY * 0.001,
}));
};
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
container.addEventListener('pointerdown', handlePointerDown);
container.addEventListener('pointermove', handlePointerMove);
container.addEventListener('pointerup', stopPointerDrag);
container.addEventListener('pointercancel', stopPointerDrag);
container.addEventListener('wheel', handleWheel, { passive: false });
container.addEventListener('contextmenu', preventContextMenu);
return () => {
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', stopPointerDrag);
container.removeEventListener('pointercancel', stopPointerDrag);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('contextmenu', preventContextMenu);
};
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -242,7 +336,8 @@ function NativeStlViewer({
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);
camera.position.set(4.5, 3.5, 5);
camera.up.set(0, 1, 0);
camera.position.set(0, 0, 6);
camera.lookAt(0, 0, 0);
let renderer: THREE.WebGLRenderer | null = null;
try {
@@ -263,10 +358,7 @@ function NativeStlViewer({
})
.then((payload) => ({
payload,
style: {
...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }),
color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6',
},
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
})),
),
).then((results) => {
@@ -308,7 +400,6 @@ function NativeStlViewer({
const group = new THREE.Group();
let baseScale = 1;
let autoSpin = 0;
scene.add(group);
let loaded = 0;
let failed = 0;
@@ -330,11 +421,11 @@ function NativeStlViewer({
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color: solidWhite ? '#f4f4f2' : style.color,
color: style.color,
opacity: style.opacity,
transparent: style.opacity < 1,
roughness: solidWhite ? 0.34 : 0.48,
metalness: solidWhite ? 0.02 : 0.08,
roughness: 0.42,
metalness: 0.04,
side: THREE.DoubleSide,
}),
);
@@ -381,12 +472,9 @@ function NativeStlViewer({
const animate = () => {
if (disposed) return;
const currentPose = poseRef.current;
if (currentPose.autoRotate) {
autoSpin += 0.004;
}
group.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin,
THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
@@ -414,10 +502,10 @@ function NativeStlViewer({
});
container.innerHTML = '';
};
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]);
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit]);
return (
<div className="h-full w-full relative">
<div className="h-full w-full relative cursor-grab active:cursor-grabbing">
<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">
@@ -452,10 +540,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [rotation, setRotation] = useState(0);
const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [solidWhite, setSolidWhite] = useState(true);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
const [dicomInfoError, setDicomInfoError] = useState('');
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
const [dicomError, setDicomError] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
@@ -619,7 +709,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
...current,
autoRotate: partial.autoRotate ?? false,
...partial,
}));
};
@@ -651,6 +740,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
setActionMessage('已生成当前 DICOM 图片 PNG');
};
const openDicomInfo = async () => {
if (!selectedProject) return;
setIsDicomInfoOpen(true);
setDicomInfoError('');
try {
setDicomInfo(await api.getDicomInfo(selectedProject.id));
} catch (error) {
setDicomInfo(null);
setDicomInfoError(error instanceof Error ? error.message : 'DICOM 信息查询失败');
}
};
const handleCreateProject = async () => {
const name = newProjectName.trim();
if (!name) {
@@ -992,6 +1093,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
>
<FileArchive size={12} /> DCM
</button>
<button
onClick={openDicomInfo}
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
title="查询 DICOM 详细信息"
>
<Info size={12} />
</button>
</div>
</div>
</div>
@@ -1006,8 +1114,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
files={stlFiles}
styles={moduleStyles}
detailLimit={selectedSolidity.limit}
solidWhite={solidWhite}
pose={modelPose}
onPoseChange={setModelPose}
/>
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
@@ -1019,14 +1127,9 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-slate-700"></p>
<button
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
<span className="text-[10px] text-slate-400"> · /Shift · </span>
</div>
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1 mb-3">
<div className="grid grid-cols-4 gap-1 rounded-xl bg-slate-100 p-1">
{solidityOptions.map((option) => (
<button
key={option.id}
@@ -1039,28 +1142,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setSolidWhite((current) => !current)}
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
solidWhite ? 'bg-white text-slate-800 border-slate-200 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
}`}
>
</button>
<button
onClick={() => updateModelPose({ autoRotate: !modelPose.autoRotate })}
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
modelPose.autoRotate ? 'bg-blue-600 text-white border-blue-600 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
}`}
>
</button>
</div>
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<p className="text-xs font-bold text-slate-700">姿</p>
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">姿</p>
<button
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
</div>
{[
{ key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
{ key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
@@ -1068,7 +1161,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{ key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
{ key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
{ key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
{ key: 'scale', label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
{ key: 'scale', label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale },
].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_1fr_42px] items-center gap-2">
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
@@ -1246,6 +1339,105 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
)}
{isDicomInfoOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
<div className="w-full max-w-3xl max-h-[82vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-slate-100 flex flex-col">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h3 className="font-bold text-slate-900">DICOM </h3>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
<button
onClick={() => setIsDicomInfoOpen(false)}
className="text-slate-400 hover:text-slate-700"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="overflow-y-auto p-6">
{dicomInfoError && <p className="text-sm font-bold text-rose-600">{dicomInfoError}</p>}
{!dicomInfo && !dicomInfoError && <p className="text-sm text-slate-400"> DICOM ...</p>}
{dicomInfo && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
title: '患者与检查',
rows: [
['患者姓名', dicomInfo.patient.name],
['患者 ID', dicomInfo.patient.id],
['检查日期', dicomInfo.study.date],
['检查类型', dicomInfo.study.modality],
['设备厂商', dicomInfo.study.manufacturer],
],
},
{
title: '序列与文件',
rows: [
['序列描述', dicomInfo.series.description],
['文件数量', dicomInfo.series.files],
['首文件', dicomInfo.series.firstFile],
['末文件', dicomInfo.series.lastFile],
['DICOM 路径', dicomInfo.project.dicomPath],
],
},
{
title: '图像矩阵与窗宽窗位',
rows: [
['Rows', dicomInfo.image.rows],
['Columns', dicomInfo.image.columns],
['Bits Allocated', dicomInfo.image.bitsAllocated],
['Window Center', dicomInfo.image.windowCenter],
['Window Width', dicomInfo.image.windowWidth],
['Rescale', `${dicomInfo.image.rescaleSlope} / ${dicomInfo.image.rescaleIntercept}`],
],
},
{
title: '空间距离',
rows: [
['像素行间距', `${displayDicomValue(dicomInfo.spacing.row)} mm`],
['像素列间距', `${displayDicomValue(dicomInfo.spacing.column)} mm`],
['切片间距', `${displayDicomValue(dicomInfo.spacing.slice)} mm`],
['间距来源', dicomInfo.spacing.sliceSource],
['切片厚度', `${displayDicomValue(dicomInfo.spacing.sliceThickness)} mm`],
['Spacing Between Slices', `${displayDicomValue(dicomInfo.spacing.spacingBetweenSlices)} mm`],
],
},
{
title: '物理尺寸',
rows: [
['宽度', `${displayDicomValue(dicomInfo.physicalSize.width)} ${dicomInfo.physicalSize.unit}`],
['高度', `${displayDicomValue(dicomInfo.physicalSize.height)} ${dicomInfo.physicalSize.unit}`],
['深度', `${displayDicomValue(dicomInfo.physicalSize.depth)} ${dicomInfo.physicalSize.unit}`],
],
},
{
title: '空间位置',
rows: [
['首张位置', dicomInfo.position.firstImagePosition?.join(', ') ?? '未知'],
['末张位置', dicomInfo.position.lastImagePosition?.join(', ') ?? '未知'],
],
},
].map((section) => (
<div key={section.title} className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<h4 className="text-xs font-bold text-slate-800 mb-3">{section.title}</h4>
<div className="space-y-2">
{section.rows.map(([label, value]) => (
<div key={label} className="flex items-start justify-between gap-4 text-xs">
<span className="text-slate-400 shrink-0">{label}</span>
<span className="text-slate-700 font-semibold text-right break-all">{displayDicomValue(value)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{projectToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl border border-slate-100">

View File

@@ -1,4 +1,4 @@
import { DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types';
import { DicomInfo, DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
@@ -52,6 +52,7 @@ export const api = {
}),
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}`),
getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`),
getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {

View File

@@ -66,4 +66,67 @@ export interface DicomPreview {
fileName: string;
windowCenter: number;
windowWidth: number;
spacing?: {
row: number;
column: number;
slice: number;
displayX?: number;
displayY?: number;
};
physicalSize?: {
width: number;
height: number;
};
}
export interface DicomInfo {
project: {
id: string;
name: string;
dicomPath: string;
};
patient: {
name: string;
id: string;
};
study: {
date: string;
description: string;
modality: string;
manufacturer: string;
};
series: {
description: string;
files: number;
firstFile: string;
lastFile: string;
};
image: {
rows: number;
columns: number;
bitsAllocated: number;
pixelRepresentation: number;
windowCenter: number;
windowWidth: number;
rescaleIntercept: number;
rescaleSlope: number;
};
spacing: {
row: number | null;
column: number | null;
slice: number | null;
sliceSource: string;
sliceThickness: number | null;
spacingBetweenSlices: number | null;
};
physicalSize: {
width: number | null;
height: number | null;
depth: number | null;
unit: string;
};
position: {
firstImagePosition: number[] | null;
lastImagePosition: number[] | null;
};
}