Files
REVOXELSEG_DICOM/WebSite/server.ts

1347 lines
44 KiB
TypeScript

import express from 'express';
import { createServer as createViteServer } from 'vite';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
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 ModuleStyleRecord {
visible: boolean;
color: string;
opacity: number;
partId: number;
}
interface UserRecord {
id: number;
name: string;
account: string;
password: string;
department: string;
date: string;
}
interface ProjectRecord {
id: string;
name: string;
createTime: string;
status: ProjectStatus;
dicomCount: number;
hasModel: boolean;
dicomPath: string;
modelPath: string;
modelCount: number;
stlFiles: string[];
maskFormats: Array<'nii' | 'nii.gz'>;
exportedMaskCount: number;
isDefault?: boolean;
moduleStyles: Record<string, ModuleStyleRecord>;
}
interface SessionRecord {
authenticated: boolean;
account: string | null;
lastUpdated: string;
}
interface AppState {
users: UserRecord[];
projects: ProjectRecord[];
session: SessionRecord;
updatedAt: string;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const dataDir = path.join(__dirname, 'data');
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;
rowSpacing: number;
columnSpacing: number;
sliceSpacing: number;
sliceThickness: number | null;
spacingBetweenSlices: number | null;
}>();
const modelPreviewCache = new Map<string, unknown>();
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
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());
}
function now() {
return new Date().toISOString();
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function listFiles(dir: string, extension: string) {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension))
.map((entry) => entry.name)
.sort(naturalFileCompare);
}
function naturalFileCompare(a: string, b: string) {
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' });
}
function publicUser(user: UserRecord) {
const { password: _password, ...rest } = user;
return rest;
}
function publicSession(state: AppState) {
const user = state.session.account
? state.users.find((candidate) => candidate.account === state.session.account)
: null;
return {
authenticated: state.session.authenticated && Boolean(user),
currentUser: user
? {
id: user.id,
name: user.name,
account: user.account,
department: user.department,
}
: null,
lastUpdated: state.session.lastUpdated,
};
}
function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function normalizeModuleStyle(
style: Partial<ModuleStyleRecord> | undefined,
index: number,
): ModuleStyleRecord {
const opacity = typeof style?.opacity === 'number' && Number.isFinite(style.opacity) ? style.opacity : 0.72;
const partId = typeof style?.partId === 'number' && Number.isFinite(style.partId) ? style.partId : index + 1;
return {
visible: typeof style?.visible === 'boolean' ? style.visible : true,
color: typeof style?.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(style.color)
? style.color
: defaultModuleColors[index % defaultModuleColors.length],
opacity: clampNumber(opacity, 0.1, 1),
partId: clampNumber(Math.round(partId), 1, 255),
};
}
function buildModuleStyles(
stlFiles: string[],
existing?: Record<string, Partial<ModuleStyleRecord>>,
) {
return stlFiles.reduce<Record<string, ModuleStyleRecord>>((acc, fileName, index) => {
acc[fileName] = normalizeModuleStyle(existing?.[fileName], index);
return acc;
}, {});
}
function buildDefaultProject(): ProjectRecord {
const stlFiles = listFiles(modelDir, '.stl');
return {
id: 'head-ct-demo',
name: '头部 CT 模型逆向体素化演示',
createTime: today(),
status: 'completed',
dicomCount: listFiles(dicomDir, '.dcm').length,
hasModel: stlFiles.length > 0,
dicomPath: 'Head_CT_DICOM',
modelPath: 'Head_CT_ReConstruct',
modelCount: stlFiles.length,
stlFiles,
maskFormats: ['nii', 'nii.gz'],
exportedMaskCount: 0,
isDefault: true,
moduleStyles: buildModuleStyles(stlFiles),
};
}
function buildEmptyProject(name: string): ProjectRecord {
return {
id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
name,
createTime: today(),
status: 'pending',
dicomCount: 0,
hasModel: false,
dicomPath: '',
modelPath: '',
modelCount: 0,
stlFiles: [],
maskFormats: ['nii', 'nii.gz'],
exportedMaskCount: 0,
moduleStyles: {},
};
}
function defaultState(): AppState {
return {
users: [
{ id: 1, name: 'Admin', account: 'admin', password: '123456', department: 'admin', date: today() },
{ id: 2, name: 'Doctor Li', account: 'doctor_li', password: '123456', department: '肝胆外科', date: today() },
],
projects: [buildDefaultProject()],
session: { authenticated: false, account: null, lastUpdated: now() },
updatedAt: now(),
};
}
function normalizeState(state: AppState): AppState {
const defaultProject = buildDefaultProject();
const savedDefaultProject = state.projects?.find((project) => project.id === defaultProject.id);
const customProjects = Array.isArray(state.projects)
? state.projects
.filter((project) => project.id !== defaultProject.id)
.map((project) => ({
...project,
stlFiles: Array.isArray(project.stlFiles) ? project.stlFiles : [],
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles),
}))
: [];
return {
...state,
projects: [
{
...defaultProject,
name: savedDefaultProject?.name ?? defaultProject.name,
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
},
...customProjects,
],
};
}
function readState(): AppState {
ensureDir(dataDir);
if (!fs.existsSync(statePath)) {
const initialState = defaultState();
writeState(initialState);
return initialState;
}
try {
const raw = fs.readFileSync(statePath, 'utf8');
return normalizeState(JSON.parse(raw) as AppState);
} catch {
const recoveredState = defaultState();
writeState(recoveredState);
return recoveredState;
}
}
function writeState(state: AppState) {
ensureDir(dataDir);
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
}
function createNiftiMask(project: ProjectRecord, compressed: boolean) {
const width = 64;
const height = 64;
const depth = 64;
const headerSize = 348;
const voxOffset = 352;
const voxelCount = width * height * depth;
const data = Buffer.alloc(voxelCount);
const center = [width / 2, height / 2, depth / 2];
for (let z = 0; z < depth; z += 1) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const dx = (x - center[0]) / 18;
const dy = (y - center[1]) / 15;
const dz = (z - center[2]) / 20;
const index = z * width * height + y * width + x;
const radius = dx * dx + dy * dy + dz * dz;
if (radius < 1) {
data[index] = 1;
}
const tumorDx = (x - 42) / 8;
const tumorDy = (y - 30) / 7;
const tumorDz = (z - 34) / 7;
if (tumorDx * tumorDx + tumorDy * tumorDy + tumorDz * tumorDz < 1) {
data[index] = 2;
}
}
}
}
const header = Buffer.alloc(voxOffset);
header.writeInt32LE(headerSize, 0);
header.writeInt16LE(3, 40);
header.writeInt16LE(width, 42);
header.writeInt16LE(height, 44);
header.writeInt16LE(depth, 46);
header.writeInt16LE(1, 48);
header.writeInt16LE(1, 50);
header.writeInt16LE(1, 52);
header.writeInt16LE(1, 54);
header.writeInt16LE(2, 70);
header.writeInt16LE(8, 72);
header.writeFloatLE(1, 76);
header.writeFloatLE(1, 80);
header.writeFloatLE(1, 84);
header.writeFloatLE(1, 88);
header.writeFloatLE(voxOffset, 108);
header.writeFloatLE(1, 112);
header.write('ReVoxelSeg demo mask', 148, 'ascii');
header.write(`Project ${project.id}`, 228, 'ascii');
header.write('n+1\0', 344, 'ascii');
const nifti = Buffer.concat([header, data]);
return compressed ? zlib.gzipSync(nifti) : nifti;
}
function findProject(state: AppState, projectId: string) {
return state.projects.find((candidate) => candidate.id === projectId);
}
function getProjectDicomFiles(project: ProjectRecord) {
if (project.id !== 'head-ct-demo') {
return [];
}
return listFiles(dicomDir, '.dcm');
}
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,
(group >> 8) & 0xff,
element & 0xff,
(element >> 8) & 0xff,
]);
const longVr = ['OB', 'OD', 'OF', 'OL', 'OW', 'SQ', 'UC', 'UR', 'UT', 'UN'];
let offset = buffer.indexOf(pattern, 132);
while (offset >= 0 && offset + 8 < buffer.length) {
const vr = buffer.subarray(offset + 4, offset + 6).toString('ascii');
if (/^[A-Z]{2}$/.test(vr)) {
if (longVr.includes(vr)) {
const length = buffer.readUInt32LE(offset + 8);
return { valueOffset: offset + 12, length, vr };
}
const length = buffer.readUInt16LE(offset + 6);
return { valueOffset: offset + 8, length, vr };
}
offset = buffer.indexOf(pattern, offset + 1);
}
return null;
}
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 attrs = parseDicomAttributes(buffer, mode);
const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
const pixelOffset = pixelTag?.valueOffset ?? -1;
const pixelLength = pixelTag?.length ?? 0;
if (!attrs.rows || !attrs.columns || pixelOffset < 0) {
throw new Error('无法解析当前 DICOM 像素数据');
}
const count = attrs.rows * attrs.columns;
const pixels = Buffer.alloc(count);
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 * (attrs.bitsAllocated / 8);
if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) {
break;
}
const raw = attrs.bitsAllocated === 16
? (attrs.pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
: buffer.readUInt8(position);
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)));
}
pixels[i] = normalized;
}
const enhancedPixels = enhanceDicomEdges(pixels, attrs.columns, attrs.rows);
return {
width: attrs.columns,
height: attrs.rows,
pixels: enhancedPixels.toString('base64'),
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,
};
}
function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') {
const preview = parseDicomPreview(filePath, mode);
return {
...preview,
pixelBuffer: Buffer.from(preview.pixels, 'base64'),
};
}
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) {
return cached;
}
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 {
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' ? volume.height : volume.width;
const pixels = Buffer.alloc(outputWidth * outputHeight);
volume.frames.forEach((frame, z) => {
for (let row = 0; row < outputHeight; row += 1) {
const sourceIndex = plane === 'sagittal'
? row * volume.width + clampedSlice
: clampedSlice * volume.width + row;
const targetIndex = row * outputWidth + z;
pixels[targetIndex] = frame[sourceIndex] ?? 0;
}
});
const cropped = cropDicomContent(pixels, outputWidth, outputHeight);
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: physical.width,
height: physical.height,
pixels: enhancedPixels.toString('base64'),
windowCenter: volume.windowCenter,
windowWidth: volume.windowWidth,
slice: clampedSlice,
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,
},
};
}
function createDicomFusionVolume(files: string[], start: number, end: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(files, mode);
const total = volume.frames.length;
const safeStart = Math.max(0, Math.min(total - 1, Number.isFinite(start) ? start : 0));
const safeEnd = Math.max(safeStart, Math.min(total - 1, Number.isFinite(end) ? end : safeStart + 49));
const maxFrames = 64;
const rangeLength = safeEnd - safeStart + 1;
const step = Math.max(1, Math.ceil(rangeLength / maxFrames));
const indices: number[] = [];
for (let index = safeStart; index <= safeEnd; index += step) {
indices.push(index);
}
if (indices[indices.length - 1] !== safeEnd) {
indices.push(safeEnd);
}
const maxTextureDimension = 256;
const textureScale = Math.min(1, maxTextureDimension / Math.max(volume.width, volume.height));
const targetWidth = Math.max(1, Math.round(volume.width * textureScale));
const targetHeight = Math.max(1, Math.round(volume.height * textureScale));
const frames = indices.map((index) => (
resampleNearest(volume.frames[index], volume.width, volume.height, targetWidth, targetHeight).toString('base64')
));
return {
width: targetWidth,
height: targetHeight,
start: safeStart,
end: safeEnd,
total,
indices,
frames,
mode,
spacing: {
row: volume.rowSpacing,
column: volume.columnSpacing,
slice: volume.sliceSpacing,
},
physicalSize: {
width: volume.width * volume.columnSpacing,
height: volume.height * volume.rowSpacing,
depth: Math.max(1, total) * volume.sliceSpacing,
unit: 'mm',
},
};
}
function enhanceDicomEdges(pixels: Buffer, width: number, height: number) {
if (width < 3 || height < 3) {
return pixels;
}
const output = Buffer.from(pixels);
for (let y = 1; y < height - 1; y += 1) {
for (let x = 1; x < width - 1; x += 1) {
const index = y * width + x;
const center = pixels[index];
const neighborAverage = (
pixels[index - 1] +
pixels[index + 1] +
pixels[index - width] +
pixels[index + width]
) / 4;
const sharpened = Math.round(center * 1.08 + (center - neighborAverage) * 0.55);
output[index] = Math.max(0, Math.min(255, sharpened));
}
}
return output;
}
function cropDicomContent(pixels: Buffer, width: number, height: number) {
const threshold = 12;
const columnHits = Array.from({ length: width }, () => 0);
const rowHits = Array.from({ length: height }, () => 0);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (pixels[y * width + x] > threshold) {
columnHits[x] += 1;
rowHits[y] += 1;
}
}
}
const minColumnHits = Math.max(4, Math.floor(height * 0.012));
const minRowHits = Math.max(4, Math.floor(width * 0.012));
let minX = columnHits.findIndex((hits) => hits >= minColumnHits);
let maxX = width - 1 - [...columnHits].reverse().findIndex((hits) => hits >= minColumnHits);
let minY = rowHits.findIndex((hits) => hits >= minRowHits);
let maxY = height - 1 - [...rowHits].reverse().findIndex((hits) => hits >= minRowHits);
if (maxX < minX || maxY < minY) {
return { pixels, width, height };
}
const padding = 18;
minX = Math.max(0, minX - padding);
minY = Math.max(0, minY - padding);
maxX = Math.min(width - 1, maxX + padding);
maxY = Math.min(height - 1, maxY + padding);
const croppedWidth = maxX - minX + 1;
const croppedHeight = maxY - minY + 1;
const croppedPixels = Buffer.alloc(croppedWidth * croppedHeight);
for (let row = 0; row < croppedHeight; row += 1) {
const sourceStart = (minY + row) * width + minX;
pixels.copy(croppedPixels, row * croppedWidth, sourceStart, sourceStart + croppedWidth);
}
return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight };
}
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, 200000));
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = [];
let sampledTriangles = 0;
const bounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
const offset = 84 + triangleIndex * 50;
if (offset + 50 > buffer.length) {
break;
}
const shouldSample = triangleIndex % step === 0;
for (let vertex = 0; vertex < 3; vertex += 1) {
const vertexOffset = offset + 12 + vertex * 12;
const x = buffer.readFloatLE(vertexOffset);
const y = buffer.readFloatLE(vertexOffset + 4);
const z = buffer.readFloatLE(vertexOffset + 8);
bounds.min.x = Math.min(bounds.min.x, x);
bounds.min.y = Math.min(bounds.min.y, y);
bounds.min.z = Math.min(bounds.min.z, z);
bounds.max.x = Math.max(bounds.max.x, x);
bounds.max.y = Math.max(bounds.max.y, y);
bounds.max.z = Math.max(bounds.max.z, z);
if (shouldSample) {
vertices.push(
Number(x.toFixed(3)),
Number(y.toFixed(3)),
Number(z.toFixed(3)),
);
}
}
if (shouldSample) {
sampledTriangles += 1;
}
}
const payload = {
fileName,
triangleCount,
sampledTriangles,
vertices,
bounds: {
min: {
x: Number(bounds.min.x.toFixed(3)),
y: Number(bounds.min.y.toFixed(3)),
z: Number(bounds.min.z.toFixed(3)),
},
max: {
x: Number(bounds.max.x.toFixed(3)),
y: Number(bounds.max.y.toFixed(3)),
z: Number(bounds.max.z.toFixed(3)),
},
},
};
modelPreviewCache.set(cacheKey, payload);
return payload;
}
function writeOctal(buffer: Buffer, value: number, offset: number, length: number) {
const text = value.toString(8).padStart(length - 1, '0').slice(-(length - 1));
buffer.write(`${text}\0`, offset, length, 'ascii');
}
function createTarEntryHeader(name: string, size: number, mtime: number) {
const header = Buffer.alloc(512);
const safeName = name.slice(0, 100);
header.write(safeName, 0, 100, 'utf8');
writeOctal(header, 0o644, 100, 8);
writeOctal(header, 0, 108, 8);
writeOctal(header, 0, 116, 8);
writeOctal(header, size, 124, 12);
writeOctal(header, Math.floor(mtime), 136, 12);
header.fill(' ', 148, 156);
header.write('0', 156, 1, 'ascii');
header.write('ustar', 257, 6, 'ascii');
header.write('00', 263, 2, 'ascii');
let checksum = 0;
for (const byte of header) {
checksum += byte;
}
writeOctal(header, checksum, 148, 8);
return header;
}
function createDicomTarGz(files: string[]) {
const chunks: Buffer[] = [];
files.forEach((fileName) => {
const filePath = path.join(dicomDir, fileName);
const stat = fs.statSync(filePath);
const data = fs.readFileSync(filePath);
chunks.push(createTarEntryHeader(`Head_CT_DICOM/${fileName}`, data.length, stat.mtimeMs / 1000));
chunks.push(data);
const remainder = data.length % 512;
if (remainder > 0) {
chunks.push(Buffer.alloc(512 - remainder));
}
});
chunks.push(Buffer.alloc(1024));
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';
const portArg = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : process.env.PORT;
const port = Number(portArg ?? 4000);
ensureDir(exportDir);
app.use(express.json());
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'revoxelseg-dicom', time: now() });
});
app.get('/api/session', (_req, res) => {
res.json(publicSession(readState()));
});
app.post('/api/login', (req, res) => {
const { account, password } = req.body as { account?: string; password?: string };
const state = readState();
const user = state.users.find((candidate) => candidate.account === account && candidate.password === password);
if (!user) {
res.status(401).json({ message: '账号或密码错误' });
return;
}
state.session = { authenticated: true, account: user.account, lastUpdated: now() };
writeState(state);
res.json(publicSession(state));
});
app.post('/api/logout', (_req, res) => {
const state = readState();
state.session = { authenticated: false, account: null, lastUpdated: now() };
writeState(state);
res.json(publicSession(state));
});
app.get('/api/users', (_req, res) => {
res.json(readState().users.map(publicUser));
});
app.get('/api/projects', (_req, res) => {
res.json(readState().projects);
});
app.post('/api/projects', (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
res.status(400).json({ message: '项目名称不能为空' });
return;
}
const state = readState();
const project = buildEmptyProject(name);
state.projects.push(project);
writeState(state);
res.status(201).json(project);
});
app.get('/api/projects/:projectId', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
res.json(project);
});
app.patch('/api/projects/:projectId', (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name) {
res.status(400).json({ message: '项目名称不能为空' });
return;
}
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
project.name = name;
writeState(state);
res.json(project);
});
app.delete('/api/projects/:projectId', (req, res) => {
const state = readState();
const index = state.projects.findIndex((project) => project.id === req.params.projectId);
if (index < 0) {
res.status(404).json({ message: '项目不存在' });
return;
}
const [deleted] = state.projects.splice(index, 1);
writeState(state);
res.json({ ok: true, deletedId: deleted.id });
});
app.patch('/api/projects/:projectId/module-styles', (req, res) => {
const incoming = req.body?.moduleStyles;
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
res.status(400).json({ message: '构件样式数据无效' });
return;
}
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
project.moduleStyles = buildModuleStyles(project.stlFiles, {
...(project.moduleStyles ?? {}),
...(incoming as Record<string, Partial<ModuleStyleRecord>>),
});
writeState(state);
res.json(project);
});
app.get('/api/projects/:projectId/dicom-preview', (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;
}
const requestedPlane = String(req.query.plane ?? '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]), mode);
payload = {
...preview,
plane,
slice,
total: files.length,
fileName: files[slice],
};
} else {
payload = {
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode),
plane,
};
}
dicomPreviewCache.set(cacheKey, payload);
res.json(payload);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' });
}
});
app.get('/api/projects/:projectId/dicom-fusion-volume', (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;
}
const requestedMode = String(req.query.mode ?? 'soft');
const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'soft';
const start = Number.parseInt(String(req.query.start ?? '0'), 10);
const end = Number.parseInt(String(req.query.end ?? '49'), 10);
try {
res.json(createDicomFusionVolume(files, start, end, mode));
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 三维融合体生成失败' });
}
});
app.get('/api/projects/:projectId/dicom-archive', (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 {
const archive = createDicomTarGz(files);
const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`;
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(archive);
} catch (error) {
res.status(500).json({ message: error instanceof Error ? error.message : 'DICOM 压缩包生成失败' });
}
});
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);
if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) {
res.status(404).json({ message: '模型文件不存在' });
return;
}
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);
const modelCount = state.projects.reduce((sum, project) => sum + project.modelCount, 0);
const exportedMaskProjects = state.projects.filter((project) => project.exportedMaskCount > 0).length;
res.json({
totalProjects: state.projects.length,
processedProjects: exportedMaskProjects,
exportedMaskProjects,
dicomCount,
modelCount,
chartData: [
{ name: 'Mon', projects: state.projects.length, processing: exportedMaskProjects },
{ name: 'Tue', projects: state.projects.length, processing: exportedMaskProjects },
{ name: 'Wed', projects: state.projects.length, processing: exportedMaskProjects },
{ name: 'Thu', projects: state.projects.length, processing: exportedMaskProjects },
{ name: 'Fri', projects: state.projects.length, processing: exportedMaskProjects },
{ name: 'Sat', projects: state.projects.length, processing: exportedMaskProjects },
{ name: 'Sun', projects: state.projects.length, processing: exportedMaskProjects },
],
});
});
app.post('/api/demo/reset', (_req, res) => {
const state = defaultState();
writeState(state);
res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) });
});
app.post('/api/projects/:projectId/export-mask', (req, res) => {
const state = readState();
const project = state.projects.find((candidate) => candidate.id === req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
const compressed = format === 'nii.gz';
const mask = createNiftiMask(project, compressed);
const filename = `${project.id}-segmentation-mask.${format}`;
const outputPath = path.join(exportDir, filename);
fs.writeFileSync(outputPath, mask);
project.exportedMaskCount += 1;
writeState(state);
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(mask);
});
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
} else {
const vite = await createViteServer({
server: { middlewareMode: true, hmr: { port: 24679 } },
appType: 'spa',
});
app.use(vite.middlewares);
}
app.listen(port, host, () => {
console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`);
warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject()));
});
}
startServer().catch((error) => {
console.error(error);
process.exit(1);
});