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