2026-05-07-16-20-46 修正DICOM比例和3D默认位姿
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
85
工程分析/实现方案-2026-05-04-05-56-34.md
Normal file
85
工程分析/实现方案-2026-05-04-05-56-34.md
Normal 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 控件重构、鼠标交互回写位姿。
|
||||||
|
- 文档:测试结果和经验记录追加。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次无需人工二次确认,文档落地后直接执行。
|
||||||
46
工程分析/实现方案-2026-05-07-16-20-46.md
Normal file
46
工程分析/实现方案-2026-05-07-16-20-46.md
Normal 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` 追加默认位姿经验。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次无需人工二次确认,文档落地后直接执行。
|
||||||
68
工程分析/测试方案-2026-05-04-05-56-34.md
Normal file
68
工程分析/测试方案-2026-05-04-05-56-34.md
Normal 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`。
|
||||||
45
工程分析/测试方案-2026-05-07-16-20-46.md
Normal file
45
工程分析/测试方案-2026-05-07-16-20-46.md
Normal 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`。
|
||||||
72
工程分析/经验记录.md
72
工程分析/经验记录.md
@@ -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 轴校正。
|
||||||
|
|||||||
49
工程分析/需求分析-2026-05-04-05-56-34.md
Normal file
49
工程分析/需求分析-2026-05-04-05-56-34.md
Normal 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 状态漂移。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
用户已明确本次无需人工二次确认,按合理工程假设直接执行。
|
||||||
34
工程分析/需求分析-2026-05-07-16-20-46.md
Normal file
34
工程分析/需求分析-2026-05-07-16-20-46.md
Normal 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 不改相机,鼠标交互与滑块显示可能不直观。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
用户已明确本次无需人工二次确认,按合理工程假设直接执行。参考图呈现为近似俯视/轴向视角,因此默认采用俯视相机和零位姿组合。
|
||||||
Reference in New Issue
Block a user