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; height: number;
windowCenter: number; windowCenter: number;
windowWidth: number; windowWidth: number;
rowSpacing: number;
columnSpacing: number;
sliceSpacing: number;
sliceThickness: number | null;
spacingBetweenSlices: number | null;
}>(); }>();
const modelPreviewCache = new Map<string, unknown>(); 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() { function today() {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()); 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(); 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) { function findExplicitTag(buffer: Buffer, group: number, element: number) {
const pattern = Buffer.from([ const pattern = Buffer.from([
group & 0xff, group & 0xff,
@@ -332,46 +418,29 @@ function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fa
function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') { function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') {
const buffer = fs.readFileSync(filePath); const buffer = fs.readFileSync(filePath);
const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010); const attrs = parseDicomAttributes(buffer, mode);
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 pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010); 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 pixelOffset = pixelTag?.valueOffset ?? -1;
const pixelLength = pixelTag?.length ?? 0; const pixelLength = pixelTag?.length ?? 0;
if (!rows || !columns || pixelOffset < 0) { if (!attrs.rows || !attrs.columns || pixelOffset < 0) {
throw new Error('无法解析当前 DICOM 像素数据'); throw new Error('无法解析当前 DICOM 像素数据');
} }
const count = rows * columns; const count = attrs.rows * attrs.columns;
const pixels = Buffer.alloc(count); const pixels = Buffer.alloc(count);
const min = windowCenter - windowWidth / 2; const min = attrs.windowCenter - attrs.windowWidth / 2;
const max = windowCenter + windowWidth / 2; const max = attrs.windowCenter + attrs.windowWidth / 2;
for (let i = 0; i < count; i += 1) { 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) { if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) {
break; break;
} }
const raw = bitsAllocated === 16 const raw = attrs.bitsAllocated === 16
? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position)) ? (attrs.pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
: buffer.readUInt8(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))); let normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255)));
if (mode === 'contrast') { if (mode === 'contrast') {
normalized = Math.max(0, Math.min(255, Math.round((normalized - 128) * 1.35 + 128))); 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; pixels[i] = normalized;
} }
const enhancedPixels = enhanceDicomEdges(pixels, columns, rows); const enhancedPixels = enhanceDicomEdges(pixels, attrs.columns, attrs.rows);
return { return {
width: columns, width: attrs.columns,
height: rows, height: attrs.rows,
pixels: enhancedPixels.toString('base64'), pixels: enhancedPixels.toString('base64'),
windowCenter, windowCenter: attrs.windowCenter,
windowWidth, windowWidth: attrs.windowWidth,
mode, 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) { function getDicomVolume(files: string[], mode: DicomDisplayMode) {
const cached = dicomVolumeCache.get(mode); const cached = dicomVolumeCache.get(mode);
if (cached) { if (cached) {
@@ -406,17 +507,59 @@ function getDicomVolume(files: string[], mode: DicomDisplayMode) {
} }
const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode)); const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode));
const sliceSpacing = estimateSliceSpacing(parsed);
const volume = { const volume = {
frames: parsed.map((frame) => frame.pixelBuffer), frames: parsed.map((frame) => frame.pixelBuffer),
width: parsed[0]?.width ?? 0, width: parsed[0]?.width ?? 0,
height: parsed[0]?.height ?? 0, height: parsed[0]?.height ?? 0,
windowCenter: parsed[0]?.windowCenter ?? 40, windowCenter: parsed[0]?.windowCenter ?? 40,
windowWidth: parsed[0]?.windowWidth ?? 400, 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); dicomVolumeCache.set(mode, volume);
return 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[]) { function warmDicomVolumeCache(files: string[]) {
setTimeout(() => { setTimeout(() => {
try { try {
@@ -447,11 +590,13 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
}); });
const cropped = cropDicomContent(pixels, outputWidth, outputHeight); 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 { return {
width: cropped.width, width: physical.width,
height: cropped.height, height: physical.height,
pixels: enhancedPixels.toString('base64'), pixels: enhancedPixels.toString('base64'),
windowCenter: volume.windowCenter, windowCenter: volume.windowCenter,
windowWidth: volume.windowWidth, windowWidth: volume.windowWidth,
@@ -459,6 +604,17 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
total: maxSlice + 1, total: maxSlice + 1,
fileName: `${plane}-${clampedSlice}`, fileName: `${plane}-${clampedSlice}`,
mode, 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 预览'); 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 step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = []; const vertices: number[] = [];
let sampledTriangles = 0; let sampledTriangles = 0;
@@ -623,6 +779,100 @@ function createDicomTarGz(files: string[]) {
return zlib.gzipSync(Buffer.concat(chunks)); 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() { async function startServer() {
const app = express(); const app = express();
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; 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) => { app.get('/api/projects/:projectId/models/:fileName', (req, res) => {
const project = findProject(readState(), req.params.projectId); const project = findProject(readState(), req.params.projectId);
const fileName = path.basename(req.params.fileName); const fileName = path.basename(req.params.fileName);

View File

@@ -8,6 +8,7 @@ import {
RotateCcw, RotateCcw,
Box, Box,
Image as ImageIcon, Image as ImageIcon,
Info,
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
@@ -20,12 +21,12 @@ import {
Upload Upload
} from 'lucide-react'; } from 'lucide-react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DicomPreview, Project } from '../types'; import { DicomInfo, DicomPreview, Project } from '../types';
import { api, downloadDicomArchive, downloadMask } from '../lib/api'; import { api, downloadDicomArchive, downloadMask } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal'; type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode']; type DisplayMode = DicomPreview['mode'];
type SolidityLevel = 'preview' | 'standard' | 'fine'; type SolidityLevel = 'preview' | 'standard' | 'fine' | 'ultra';
interface ModuleStyle { interface ModuleStyle {
visible: boolean; visible: boolean;
@@ -41,7 +42,6 @@ interface ModelPose {
translateY: number; translateY: number;
translateZ: number; translateZ: number;
scale: number; scale: number;
autoRotate: boolean;
} }
interface ModelPreviewPayload { interface ModelPreviewPayload {
@@ -56,6 +56,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }
{ id: 'preview', label: '预览', limit: 6000 }, { id: 'preview', label: '预览', limit: 6000 },
{ id: 'standard', label: '标准', limit: 16000 }, { id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 }, { id: 'fine', label: '精细', limit: 36000 },
{ id: 'ultra', label: '超精细', limit: 72000 },
]; ];
const defaultModelPose: ModelPose = { const defaultModelPose: ModelPose = {
rotateX: 0, rotateX: 0,
@@ -65,7 +66,6 @@ const defaultModelPose: ModelPose = {
translateY: 0, translateY: 0,
translateZ: 0, translateZ: 0,
scale: 1, scale: 1,
autoRotate: true,
}; };
function drawFallbackModelPreview( function drawFallbackModelPreview(
@@ -181,6 +181,13 @@ function safeFilePart(value: string) {
return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom'; 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 }) { function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -205,18 +212,19 @@ function NativeStlViewer({
files, files,
styles, styles,
detailLimit, detailLimit,
solidWhite,
pose, pose,
onPoseChange,
}: { }: {
projectId: string; projectId: string;
files: string[]; files: string[];
styles: Record<string, ModuleStyle>; styles: Record<string, ModuleStyle>;
detailLimit: number; detailLimit: number;
solidWhite: boolean;
pose: ModelPose; pose: ModelPose;
onPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
}) { }) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const poseRef = useRef<ModelPose>(pose); const poseRef = useRef<ModelPose>(pose);
const onPoseChangeRef = useRef(onPoseChange);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('准备加载模型'); const [status, setStatus] = useState('准备加载模型');
@@ -224,6 +232,92 @@ function NativeStlViewer({
poseRef.current = pose; poseRef.current = pose;
}, [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(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@@ -242,7 +336,8 @@ function NativeStlViewer({
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color('#f8fafc'); 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); 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); camera.lookAt(0, 0, 0);
let renderer: THREE.WebGLRenderer | null = null; let renderer: THREE.WebGLRenderer | null = null;
try { try {
@@ -263,10 +358,7 @@ function NativeStlViewer({
}) })
.then((payload) => ({ .then((payload) => ({
payload, payload,
style: { style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }),
color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6',
},
})), })),
), ),
).then((results) => { ).then((results) => {
@@ -308,7 +400,6 @@ function NativeStlViewer({
const group = new THREE.Group(); const group = new THREE.Group();
let baseScale = 1; let baseScale = 1;
let autoSpin = 0;
scene.add(group); scene.add(group);
let loaded = 0; let loaded = 0;
let failed = 0; let failed = 0;
@@ -330,11 +421,11 @@ function NativeStlViewer({
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
geometry, geometry,
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color: solidWhite ? '#f4f4f2' : style.color, color: style.color,
opacity: style.opacity, opacity: style.opacity,
transparent: style.opacity < 1, transparent: style.opacity < 1,
roughness: solidWhite ? 0.34 : 0.48, roughness: 0.42,
metalness: solidWhite ? 0.02 : 0.08, metalness: 0.04,
side: THREE.DoubleSide, side: THREE.DoubleSide,
}), }),
); );
@@ -381,12 +472,9 @@ function NativeStlViewer({
const animate = () => { const animate = () => {
if (disposed) return; if (disposed) return;
const currentPose = poseRef.current; const currentPose = poseRef.current;
if (currentPose.autoRotate) {
autoSpin += 0.004;
}
group.rotation.set( group.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX), THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin, THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ), THREE.MathUtils.degToRad(currentPose.rotateZ),
); );
group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
@@ -414,10 +502,10 @@ function NativeStlViewer({
}); });
container.innerHTML = ''; container.innerHTML = '';
}; };
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]); }, [projectId, files.join('|'), JSON.stringify(styles), detailLimit]);
return ( 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" /> <div ref={containerRef} className="absolute inset-0" />
{progress < 100 && ( {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="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 [rotation, setRotation] = useState(0);
const [isSliceChanging, setIsSliceChanging] = useState(false); const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard'); const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [solidWhite, setSolidWhite] = useState(true);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose); const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({}); const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null); 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 [dicomError, setDicomError] = useState('');
const [newProjectName, setNewProjectName] = useState(''); const [newProjectName, setNewProjectName] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
@@ -619,7 +709,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const updateModelPose = (partial: Partial<ModelPose>) => { const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({ setModelPose((current) => ({
...current, ...current,
autoRotate: partial.autoRotate ?? false,
...partial, ...partial,
})); }));
}; };
@@ -651,6 +740,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
setActionMessage('已生成当前 DICOM 图片 PNG'); 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 handleCreateProject = async () => {
const name = newProjectName.trim(); const name = newProjectName.trim();
if (!name) { if (!name) {
@@ -992,6 +1093,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
> >
<FileArchive size={12} /> DCM <FileArchive size={12} /> DCM
</button> </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> </div>
</div> </div>
@@ -1006,8 +1114,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
files={stlFiles} files={stlFiles}
styles={moduleStyles} styles={moduleStyles}
detailLimit={selectedSolidity.limit} detailLimit={selectedSolidity.limit}
solidWhite={solidWhite}
pose={modelPose} pose={modelPose}
onPoseChange={setModelPose}
/> />
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]"> <div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label} 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="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-slate-700"></p> <p className="text-xs font-bold text-slate-700"></p>
<button <span className="text-[10px] text-slate-400"> · /Shift · </span>
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
</div> </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) => ( {solidityOptions.map((option) => (
<button <button
key={option.id} key={option.id}
@@ -1039,28 +1142,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</button> </button>
))} ))}
</div> </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>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3"> <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: '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 }, { 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: '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: '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: '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) => ( ].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_1fr_42px] items-center gap-2"> <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> <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> </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 && ( {projectToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm"> <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"> <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> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, { 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') => 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}`), 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'), getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () => resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {

View File

@@ -66,4 +66,67 @@ export interface DicomPreview {
fileName: string; fileName: string;
windowCenter: number; windowCenter: number;
windowWidth: 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;
};
} }

View File

@@ -0,0 +1,85 @@
# 实现方案 - 2026-05-04-05-56-34
## 修改目标
修正 DICOM 矢状面/冠状面的物理比例,新增 DICOM 详细信息查询;简化 3D 模型显示控制,加入更高实体化档位,并实现画布内鼠标旋转、平移、滚轮缩放且同步整体位姿控件。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/types.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `工程分析/经验记录.md`
## 技术路线
### 1. DICOM 空间信息解析
- 扩展后端 DICOM tag 解析:
- Pixel Spacing `(0028,0030)`:单张切片内行/列像素实际距离。
- Slice Thickness `(0018,0050)`
- Spacing Between Slices `(0018,0088)`
- Image Position Patient `(0020,0032)`:优先用相邻切片空间位置差计算真实切片间距。
- Patient、Study、Series、Modality、Manufacturer、Rows、Columns、Window、Rescale 等基础信息。
- 在 DICOM volume cache 中保存 `rowSpacing``columnSpacing``sliceSpacing`
### 2. 多平面物理比例重采样
- 当前矢状面/冠状面生成后先得到原始矩阵。
- 根据物理尺寸计算目标比例:
- 横向:`切片数 * sliceSpacing`
- 矢状面纵向:`rows * rowSpacing`
- 冠状面纵向:`columns * columnSpacing`
- 以较小物理间距作为输出采样单位,将重建图像最近邻重采样到接近真实物理比例的像素宽高。
- 返回 `spacing``physicalSize`,供前端信息展示。
### 3. DICOM 详细信息查询
- 新增后端接口:`GET /api/projects/:projectId/dicom-info`
- 返回默认项目第一张 DICOM 与序列聚合信息:
- patient、study、series、image、window、spacing、sequence、source 等分组。
- 前端 DICOM 影像页新增“信息”按钮,打开弹窗/面板展示基本信息、像素间距、切片间距、图像矩阵、物理尺寸、文件数量、首尾文件等。
### 4. 3D 模型控制简化与增强
- 去掉“白色实体”开关和“自动旋转”开关。
- 默认模型不自动旋转,正向放置。
- 实体化档位改为:`预览 / 标准 / 精细 / 超精细`
- 后端 STL preview 抽样上限提升到 `72000`,前端超精细档使用 `72000`
- 重置位姿按钮移动到“整体位姿”标题右侧。
### 5. 鼠标/滚轮位姿交互
-`NativeStlViewer` 容器上监听 pointer 和 wheel
- 左键拖拽:旋转 X/Y。
- 右键或 Shift+拖拽:平移 X/Y。
- 滚轮:缩放。
- 交互时通过 `onPoseChange` 回写 React state使滑块数值同步变化。
- 禁用浏览器右键菜单,避免右键平移时弹出菜单。
- 位姿仍作用于整体 group不改变 STL 构件相对位置。
## 数据流或交互流程
1. 前端请求 DICOM preview后端解析/缓存体数据和空间信息,按真实物理比例输出矢状面/冠状面。
2. 前端点击 DICOM 信息按钮,请求 dicom-info弹窗展示元数据和空间参数。
3. 前端进入 3D 模型页,按当前实体化档位请求 STL preview。
4. 用户拖拽/滚轮操作画布,`NativeStlViewer` 更新位姿并回写父组件,右侧滑块同步变化。
5. 用户点击重置位姿,模型回到默认正向摆放。
## 兼容性与回滚方案
- 若某些 DICOM tag 缺失,后端使用默认 spacing `1mm`,并在详情中展示“未知/默认”。
- 多平面重采样使用最近邻,避免引入新依赖;如比例异常可回滚到原始矩阵输出。
- 超精细档可能更慢,但保留低档位可回退。
- 鼠标交互只作用于项目库 3D 视图,不影响 DICOM、导出和逆向工作区。
## 预计文件变更
- 后端DICOM metadata/spacing 解析、多平面重采样、dicom-info API、STL 上限。
- 前端DICOM 信息弹窗、3D 控件重构、鼠标交互回写位姿。
- 文档:测试结果和经验记录追加。
## 人工审核状态
用户已明确本次无需人工二次确认,文档落地后直接执行。

View File

@@ -0,0 +1,46 @@
# 实现方案 - 2026-05-07-16-20-46
## 修改目标
修正项目库 3D 模型页默认位姿,使初次打开和点击“重置位姿”都恢复到类似参考图的正常俯视/正向姿态。
## 涉及路径
- `WebSite/src/components/ProjectLibrary.tsx`
- `工程分析/经验记录.md`
## 技术路线
1. 默认位姿
- 保持 `defaultModelPose` 的旋转、平移和缩放为中性值,避免默认滑块显示已经偏转。
- 重置位姿继续设置为 `defaultModelPose`
2. 默认相机
-`NativeStlViewer` 默认 camera 从斜向等距视角调整为更接近参考图的俯视视角。
- 使用 `camera.position.set(0, 0, 6)``camera.up.set(0, 1, 0)``camera.lookAt(0, 0, 0)`,让模型以 XY 平面正向进入视野。
- resize 后保留相机方向。
3. 视觉验证
- 进入 3D 模型页后,模型不再以明显斜向等距视角显示。
- 通过鼠标/滚轮改变位姿后,点击重置回到标准默认视角。
4. 与上一轮未提交改动合并
- 保留并验证 DICOM 空间比例、DICOM 信息面板、3D 超精细档、鼠标交互同步等改动。
## 数据流或交互流程
用户进入项目库 -> 点击 3D 模型 -> 前端创建 Three.js camera 并使用默认俯视相机 -> STL group 居中缩放 -> 默认位姿滑块为 0/0/0 与缩放 1 -> 用户交互后可点击重置恢复。
## 兼容性与回滚方案
- 如果参考视角需要再微调,可只调整 camera position/up不影响 STL 数据和后端接口。
- 回滚可恢复相机到原先 `(4.5, 3.5, 5)` 等距视角。
## 预计文件变更
- `ProjectLibrary.tsx` 中相机默认位置和说明文字。
- `经验记录.md` 追加默认位姿经验。
## 人工审核状态
用户已明确本次无需人工二次确认,文档落地后直接执行。

View File

@@ -0,0 +1,68 @@
# 测试方案 - 2026-05-04-05-56-34
## 静态检查
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
- 执行 `npm run build`,确认生产构建通过。
## 集成测试
- DICOM preview API
- 验证 axial/sagittal/coronal 均返回。
- 验证 sagittal/coronal 返回的 `spacing``physicalSize``width/height` 合理。
- DICOM info API
- 验证返回 patient/study/series/image/window/spacing/sequence/source 分组。
- 验证 Pixel Spacing、Slice Spacing、Rows、Columns、文件数量等信息存在。
- STL preview API
- 验证 `limit=6000/16000/36000/72000` 返回不报错。
## 关键业务场景验证
- DICOM 影像页:
- 矢状面/冠状面不再异常扁平。
- 点击信息按钮弹出详情面板。
- 详情面板展示像素间距、切片间距、切片厚度、矩阵、物理尺寸。
- 3D 模型页:
- 不再显示白色实体和自动旋转开关。
- 实体化档位包含“超精细”。
- 默认正向静止摆放。
- 重置位姿按钮位于“整体位姿”标题右侧。
- 左键拖拽旋转,右键/Shift 拖拽平移,滚轮缩放。
- 画布交互后右侧整体位姿滑块数值同步变化。
## 医学影像数据相关边界验证
- DICOM tag 缺失时使用 fallback不导致接口 500。
- 切片间距优先 Image Position Patient 差值,再 fallback 到 Spacing Between Slices、Slice Thickness、1mm。
- 多平面物理比例不改变切片总数和当前切片编号逻辑。
## 回归风险
- 物理比例重采样可能增大图像尺寸,需要限制最大输出尺寸。
- 超精细 STL 预览可能变慢,需要保留低档位。
- 鼠标交互需避免页面滚动和右键菜单干扰。
## 人工审核状态
用户已明确本次无需人工二次确认,按本方案执行验证。
## 执行结果
- `npm run lint`:通过。
- `npm run build`通过Vite 仍提示 bundle 超过 500 kB为现有 Three.js/Recharts 依赖导致的非阻断警告。
- DICOM preview API 验证:
- `sagittal` 返回 `384x421``spacing.slice=1mm``displayY=0.78125mm`,物理尺寸约 `300mm x 328.906mm`
- `coronal` 返回 `384x512``spacing.slice=1mm``displayY=0.78125mm`,物理尺寸约 `300mm x 400mm`
- DICOM info API 验证:
- 返回患者 `WANG FANG``CT`、文件数 `300`、矩阵 `512x512`
- 返回 `row/column spacing=0.781mm``slice=1mm`,来源 `ImagePositionPatient`
- 返回物理尺寸 `400mm x 400mm x 299mm`
- STL preview API 验证:
- `limit=6000/16000/36000/72000` 均返回,首个 STL 在超精细档达到原始 `17384` 个三角面。
- 无头 Chrome 前端验证:
- DICOM 信息按钮存在。
- DICOM 信息弹窗存在,并展示像素行间距、切片间距、物理尺寸。
- 3D 页存在“超精细”,已移除“白色实体”和“自动旋转”。
- 默认位姿滑块为 `0,0,0 / 0,0,0 / 1`
- 页面包含“左键旋转”和“滚轮缩放”提示。
- 已重新部署到 `http://192.168.3.11:4000/`tmux 会话:`revoxelseg-dicom`

View File

@@ -0,0 +1,45 @@
# 测试方案 - 2026-05-07-16-20-46
## 静态检查
- 执行 `npm run lint`
- 执行 `npm run build`
## 集成测试
- 验证 STL preview API 在 `6000/16000/36000/72000` 档位下可返回。
- 验证 DICOM preview 与 DICOM info API 仍可返回,确保上一轮相关改动未受影响。
## 前端验证
- 无头 Chrome 登录后进入项目库 3D 模型页:
- 控件包含“超精细”,不包含“白色实体/自动旋转”。
- 默认位姿滑块为旋转 0、平移 0、缩放 1。
- canvas 非空。
- 模拟拖拽/滚轮后检查位姿数值变化。
- 点击重置位姿后检查数值恢复默认。
## 回归风险
- 无头 Chrome 可能走二维兜底预览,但仍可验证控件和位姿状态。
- 真实 WebGL 视角需要用户目视确认参考图匹配度;本次以默认俯视相机为工程修正。
## 人工审核状态
用户已明确本次无需人工二次确认,按本方案执行验证。
## 执行结果
- `npm run lint`:通过。
- `npm run build`通过Vite 大 chunk 体积提示为非阻断警告。
- 已将 3D 默认相机从斜向等距视角改为俯视相机:`camera.position=(0,0,6)``lookAt(0,0,0)`
- 无头 Chrome 前端验证:
- 3D 页 canvas 非空,尺寸 `1172x567`
- 默认位姿滑块为 `0,0,0 / 0,0,0 / 1`
- “超精细”存在,“白色实体/自动旋转”不存在。
- DICOM 信息面板仍可打开。
- 关联 API 回归验证:
- DICOM 多平面物理比例接口正常。
- DICOM 信息接口正常。
- STL 四档预览接口正常。
- 已重新部署到 `http://192.168.3.11:4000/`tmux 会话:`revoxelseg-dicom`

View File

@@ -505,3 +505,75 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
页面级标题应由全局导航或内容区二选一承担;当前对象信息只保留在最醒目的单一位置,减少重复文本造成的噪声。 页面级标题应由全局导航或内容区二选一承担;当前对象信息只保留在最醒目的单一位置,减少重复文本造成的噪声。
## 2026-05-04-05-56-34 DICOM 多平面物理比例
A. 具体问题
矢状面和冠状面只按像素矩阵重建,没有考虑切片间距与单张图像内像素间距,导致图像观感过扁。
B. 产生问题原因
后端重建平面时直接使用 `切片数 x 行/列数` 作为输出尺寸,默认把切片方向和像素方向当成等距网格。
C. 解决问题方案
解析 `PixelSpacing``SliceThickness``SpacingBetweenSlices``ImagePositionPatient`;优先用相邻 `ImagePositionPatient` 距离估计真实切片间距,并按 `sliceSpacing` 与像素间距做最近邻重采样,返回 spacing 与 physicalSize。
D. 后续如何避免问题
医学影像任意重建平面都必须带着物理 spacing 计算,不应只看像素数量;当 DICOM tag 缺失时要明确 fallback 来源。
## 2026-05-04-05-56-34 DICOM 信息面板
A. 具体问题
前端缺少 DICOM 详细信息查询,用户无法看到像素间距、切片间距等判断空间比例的关键信息。
B. 产生问题原因
原有 API 只服务于灰度预览,没有暴露 DICOM 元数据和序列级空间统计。
C. 解决问题方案
新增 `GET /api/projects/:projectId/dicom-info`返回患者、检查、序列、图像矩阵、窗宽窗位、spacing、物理尺寸和首尾切片位置前端增加“信息”按钮和 DICOM 详细信息弹窗。
D. 后续如何避免问题
影像显示功能旁应提供可审计的元数据入口,特别是任何影响几何比例、配准和导出的空间参数。
## 2026-05-04-05-56-34 3D 模型交互控制简化
A. 具体问题
3D 模型页存在不需要的白色实体模式和自动旋转,同时缺少更高细节档位;鼠标拖拽、滚轮等画布操作不能同步到右侧位姿控件。
B. 产生问题原因
前一版位姿控制主要依赖右侧滑块,画布本身只负责渲染;显示开关也偏演示型,没有完全贴近用户的实际浏览习惯。
C. 解决问题方案
移除白色实体和自动旋转;新增“超精细”档,后端 STL 抽样上限提升到 `72000`;画布监听左键旋转、右键或 Shift 平移、滚轮缩放,并回写整体位姿 state。
D. 后续如何避免问题
三维浏览默认应遵循常见交互习惯UI 控件与鼠标操作必须共享同一份状态;演示型开关要及时剔除,避免干扰核心工作流。
## 2026-05-07-16-20-46 3D 默认位姿
A. 具体问题
网页端 3D 模型默认位姿看起来不像用户参考图中的正常位姿,打开后更像斜向观察。
B. 产生问题原因
默认 Three.js 相机使用 `(4.5, 3.5, 5)` 斜向等距视角,而用户期望的是接近俯视/轴向的标准视角。
C. 解决问题方案
将默认相机改为俯视方向:`camera.up=(0,1,0)``camera.position=(0,0,6)``lookAt(0,0,0)`;保留默认位姿滑块为旋转 0、平移 0、缩放 1重置位姿也回到同一基准。
D. 后续如何避免问题
默认位姿应该由相机预设和模型位姿共同定义;如果用户提供标准视图截图,应优先匹配相机视角,再决定是否需要固定模型 Z 轴校正。

View File

@@ -0,0 +1,49 @@
# 需求分析 - 2026-05-04-05-56-34
## 原始需求摘要
用户要求严格使用代码编纂工作流,但本次需求分析、实现方案、测试方案、执行修改均不需要人工二次确认。当前需求包括:
1. DICOM 矢状面、冠状面需要根据切片间距、单张切片内像素间距进行真实物理比例计算,当前图像看起来过扁。
2. DICOM 影像增加详细信息查询按钮,提取 DICOM 基本信息,并列出像素间距、切片间距等必要的新信息。
3. 3D 模型去掉白色实体模式和自动旋转;实体化程度在“预览、标准、精细”基础上再增加一个更细致档位;默认正向摆放;重置位姿放到“整体位姿”标题右侧;支持在图中用鼠标/滚轮操作旋转、平移、缩放,并把操作变化映射回整体位姿控件。
## 业务目标
DICOM 多平面重建应尽量符合真实空间比例辅助用户正确理解矢状面和冠状面的解剖形态DICOM 信息查询应展示基础元数据和关键空间参数3D 模型交互应贴近常见三维软件操作习惯,减少无用开关,增强手动操控。
## 输入与输出
- 输入:
- `Head_CT_DICOM/` 中 DICOM 序列及其 DICOM tag。
- `Head_CT_ReConstruct/` 中 STL 模型。
- 用户鼠标拖拽、滚轮、位姿滑块操作。
- 输出:
- 物理比例修正后的矢状面/冠状面预览。
- DICOM 详细信息面板。
- 调整后的 3D 实体化档位和手动位姿交互。
## 影响范围
- `WebSite/server.ts`
- 解析 Pixel Spacing、Slice Thickness、Spacing Between Slices、Image Position Patient 等 DICOM 空间信息。
- 多平面重建按物理比例重采样。
- 新增 DICOM 信息 API。
- `WebSite/src/types.ts`
- 补充 DICOM 预览空间信息和详情信息类型。
- `WebSite/src/lib/api.ts`
- 增加 DICOM 详情接口。
- `WebSite/src/components/ProjectLibrary.tsx`
- 增加 DICOM 信息按钮/弹窗。
- 修改 3D 模型控件和鼠标交互。
## 风险点
- 当前 DICOM 解析器是轻量解析器,不覆盖所有 DICOM 传输语法;本次仍以现有数据集可解析为目标。
- 多平面物理重采样会改变返回图像尺寸,前端布局需要继续保持自适应。
- 高实体化档位会显著增加 STL 预览顶点数和浏览器渲染压力。
- 自研鼠标旋转/平移/滚轮缩放需要与位姿滑块状态双向同步,避免 UI 状态漂移。
## 待确认问题
用户已明确本次无需人工二次确认,按合理工程假设直接执行。

View File

@@ -0,0 +1,34 @@
# 需求分析 - 2026-05-07-16-20-46
## 原始需求摘要
用户要求严格使用代码编纂工作流,本次无需人工二次确认。用户指出正常情况下的 3D 模型默认位姿应类似图 1模型以更标准的正向/俯视视角展示,而当前网页端默认位姿感觉不正确。
## 业务目标
项目库 3D 模型页首次打开时,应直接呈现接近用户参考图的正常默认视角,避免用户进入页面后看到斜向、侧向或难以判断解剖结构方向的模型姿态。重置位姿也应恢复到同一标准默认视角。
## 输入与输出
- 输入:`Head_CT_ReConstruct/` 中 STL 模型,以及用户提供的标准位姿参考截图。
- 输出:调整后的默认相机视角/默认整体位姿,确保默认和重置位姿一致。
## 影响范围
- `WebSite/src/components/ProjectLibrary.tsx`
- `defaultModelPose`
- `NativeStlViewer` 的默认 camera position / lookAt / up 方向
- 可能涉及位姿滑块初始值与重置逻辑
- 延续上一轮未提交相关改动:
- DICOM 空间比例和信息接口
- 3D 模型超精细档、鼠标交互和位姿同步
## 风险点
- STL 坐标系不一定与 Three.js 默认相机坐标完全一致,需要通过合理默认 camera 与旋转组合匹配参考图。
- 若只改 camera 不改 reset用户重置后仍可能回到旧姿态。
- 若只改 group rotation 不改相机,鼠标交互与滑块显示可能不直观。
## 待确认问题
用户已明确本次无需人工二次确认,按合理工程假设直接执行。参考图呈现为近似俯视/轴向视角,因此默认采用俯视相机和零位姿组合。