2026-05-04-04-58-36 优化DICOM缓存和三维融合预览
This commit is contained in:
@@ -7,6 +7,8 @@ import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type ProjectStatus = 'pending' | 'completed' | 'processing';
|
||||
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
|
||||
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
|
||||
|
||||
interface UserRecord {
|
||||
id: number;
|
||||
@@ -54,6 +56,15 @@ const exportDir = path.join(__dirname, 'exports');
|
||||
const statePath = path.join(dataDir, 'state.json');
|
||||
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
|
||||
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
|
||||
const dicomPreviewCache = new Map<string, unknown>();
|
||||
const dicomVolumeCache = new Map<DicomDisplayMode, {
|
||||
frames: Buffer[];
|
||||
width: number;
|
||||
height: number;
|
||||
windowCenter: number;
|
||||
windowWidth: number;
|
||||
}>();
|
||||
const modelPreviewCache = new Map<string, unknown>();
|
||||
|
||||
function today() {
|
||||
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date());
|
||||
@@ -302,7 +313,20 @@ function findExplicitTag(buffer: Buffer, group: number, element: number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDicomPreview(filePath: string) {
|
||||
function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fallbackWidth: number) {
|
||||
if (mode === 'bone') {
|
||||
return { windowCenter: 500, windowWidth: 2000 };
|
||||
}
|
||||
if (mode === 'soft') {
|
||||
return { windowCenter: 40, windowWidth: 400 };
|
||||
}
|
||||
if (mode === 'contrast') {
|
||||
return { windowCenter: 80, windowWidth: 180 };
|
||||
}
|
||||
return { windowCenter: fallbackCenter, windowWidth: fallbackWidth };
|
||||
}
|
||||
|
||||
function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010);
|
||||
const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011);
|
||||
@@ -318,8 +342,9 @@ function parseDicomPreview(filePath: string) {
|
||||
const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0;
|
||||
const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16;
|
||||
const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0;
|
||||
const windowCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40;
|
||||
const windowWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400;
|
||||
const fallbackCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40;
|
||||
const fallbackWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400;
|
||||
const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth);
|
||||
const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0;
|
||||
const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1;
|
||||
const pixelOffset = pixelTag?.valueOffset ?? -1;
|
||||
@@ -343,7 +368,10 @@ function parseDicomPreview(filePath: string) {
|
||||
? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
|
||||
: buffer.readUInt8(position);
|
||||
const hu = raw * rescaleSlope + rescaleIntercept;
|
||||
const normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255)));
|
||||
let normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255)));
|
||||
if (mode === 'contrast') {
|
||||
normalized = Math.max(0, Math.min(255, Math.round((normalized - 128) * 1.35 + 128)));
|
||||
}
|
||||
pixels[i] = normalized;
|
||||
}
|
||||
|
||||
@@ -353,34 +381,62 @@ function parseDicomPreview(filePath: string) {
|
||||
pixels: pixels.toString('base64'),
|
||||
windowCenter,
|
||||
windowWidth,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDicomPixels(filePath: string) {
|
||||
const preview = parseDicomPreview(filePath);
|
||||
function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') {
|
||||
const preview = parseDicomPreview(filePath, mode);
|
||||
return {
|
||||
...preview,
|
||||
pixelBuffer: Buffer.from(preview.pixels, 'base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal', slice: number) {
|
||||
const first = parseDicomPixels(path.join(dicomDir, files[0]));
|
||||
const maxSlice = plane === 'sagittal' ? first.width - 1 : first.height - 1;
|
||||
function getDicomVolume(files: string[], mode: DicomDisplayMode) {
|
||||
const cached = dicomVolumeCache.get(mode);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode));
|
||||
const volume = {
|
||||
frames: parsed.map((frame) => frame.pixelBuffer),
|
||||
width: parsed[0]?.width ?? 0,
|
||||
height: parsed[0]?.height ?? 0,
|
||||
windowCenter: parsed[0]?.windowCenter ?? 40,
|
||||
windowWidth: parsed[0]?.windowWidth ?? 400,
|
||||
};
|
||||
dicomVolumeCache.set(mode, volume);
|
||||
return volume;
|
||||
}
|
||||
|
||||
function warmDicomVolumeCache(files: string[]) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
getDicomVolume(files, 'default');
|
||||
getDicomVolume(files, 'soft');
|
||||
} catch (error) {
|
||||
console.warn('DICOM volume warmup failed:', error);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'axial'>, slice: number, mode: DicomDisplayMode) {
|
||||
const volume = getDicomVolume(files, mode);
|
||||
const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1;
|
||||
const clampedSlice = Math.max(0, Math.min(maxSlice, slice));
|
||||
const outputWidth = files.length;
|
||||
const outputHeight = plane === 'sagittal' ? first.height : first.width;
|
||||
const outputHeight = plane === 'sagittal' ? volume.height : volume.width;
|
||||
const pixels = Buffer.alloc(outputWidth * outputHeight);
|
||||
|
||||
files.forEach((fileName, z) => {
|
||||
const frame = parseDicomPixels(path.join(dicomDir, fileName));
|
||||
|
||||
volume.frames.forEach((frame, z) => {
|
||||
for (let row = 0; row < outputHeight; row += 1) {
|
||||
const sourceIndex = plane === 'sagittal'
|
||||
? row * frame.width + clampedSlice
|
||||
: clampedSlice * frame.width + row;
|
||||
? row * volume.width + clampedSlice
|
||||
: clampedSlice * volume.width + row;
|
||||
const targetIndex = row * outputWidth + z;
|
||||
pixels[targetIndex] = frame.pixelBuffer[sourceIndex] ?? 0;
|
||||
pixels[targetIndex] = frame[sourceIndex] ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -388,14 +444,65 @@ function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal'
|
||||
width: outputWidth,
|
||||
height: outputHeight,
|
||||
pixels: pixels.toString('base64'),
|
||||
windowCenter: first.windowCenter,
|
||||
windowWidth: first.windowWidth,
|
||||
windowCenter: volume.windowCenter,
|
||||
windowWidth: volume.windowWidth,
|
||||
slice: clampedSlice,
|
||||
total: maxSlice + 1,
|
||||
fileName: `${plane}-${clampedSlice}`,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function createStlPreview(filePath: string, fileName: string, limit: number) {
|
||||
const cacheKey = `${fileName}:${limit}`;
|
||||
const cached = modelPreviewCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
if (buffer.length < 84) {
|
||||
throw new Error('STL 文件内容为空或不完整');
|
||||
}
|
||||
|
||||
const triangleCount = buffer.readUInt32LE(80);
|
||||
const expectedLength = 84 + triangleCount * 50;
|
||||
if (triangleCount <= 0 || expectedLength > buffer.length + 1024) {
|
||||
throw new Error('当前仅支持二进制 STL 预览');
|
||||
}
|
||||
|
||||
const sampleLimit = Math.max(100, Math.min(limit, 12000));
|
||||
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
|
||||
const vertices: number[] = [];
|
||||
let sampledTriangles = 0;
|
||||
|
||||
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += step) {
|
||||
const offset = 84 + triangleIndex * 50;
|
||||
if (offset + 50 > buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (let vertex = 0; vertex < 3; vertex += 1) {
|
||||
const vertexOffset = offset + 12 + vertex * 12;
|
||||
vertices.push(
|
||||
Number(buffer.readFloatLE(vertexOffset).toFixed(3)),
|
||||
Number(buffer.readFloatLE(vertexOffset + 4).toFixed(3)),
|
||||
Number(buffer.readFloatLE(vertexOffset + 8).toFixed(3)),
|
||||
);
|
||||
}
|
||||
sampledTriangles += 1;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName,
|
||||
triangleCount,
|
||||
sampledTriangles,
|
||||
vertices,
|
||||
};
|
||||
modelPreviewCache.set(cacheKey, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
|
||||
@@ -512,26 +619,37 @@ async function startServer() {
|
||||
}
|
||||
|
||||
const requestedPlane = String(req.query.plane ?? 'axial');
|
||||
const plane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial';
|
||||
const plane: DicomPlane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial';
|
||||
const requestedMode = String(req.query.mode ?? 'default');
|
||||
const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'default';
|
||||
const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10);
|
||||
const cacheKey = `${project.id}:${plane}:${mode}:${Number.isFinite(requestedSlice) ? requestedSlice : 0}`;
|
||||
if (dicomPreviewCache.has(cacheKey)) {
|
||||
res.json(dicomPreviewCache.get(cacheKey));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown;
|
||||
if (plane === 'axial') {
|
||||
const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0));
|
||||
const preview = parseDicomPreview(path.join(dicomDir, files[slice]));
|
||||
res.json({
|
||||
const preview = parseDicomPreview(path.join(dicomDir, files[slice]), mode);
|
||||
payload = {
|
||||
...preview,
|
||||
plane,
|
||||
slice,
|
||||
total: files.length,
|
||||
fileName: files[slice],
|
||||
});
|
||||
return;
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode),
|
||||
plane,
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0),
|
||||
plane,
|
||||
});
|
||||
dicomPreviewCache.set(cacheKey, payload);
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' });
|
||||
}
|
||||
@@ -549,6 +667,23 @@ async function startServer() {
|
||||
res.sendFile(path.join(modelDir, fileName));
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => {
|
||||
const project = findProject(readState(), req.params.projectId);
|
||||
const fileName = path.basename(req.params.fileName);
|
||||
const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10);
|
||||
|
||||
if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) {
|
||||
res.status(404).json({ message: '模型文件不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(createStlPreview(path.join(modelDir, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/overview', (_req, res) => {
|
||||
const state = readState();
|
||||
const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0);
|
||||
@@ -617,6 +752,7 @@ async function startServer() {
|
||||
|
||||
app.listen(port, host, () => {
|
||||
console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`);
|
||||
warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user