Files
REVOXELSEG_DICOM/WebSite/server.ts

2760 lines
92 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
type SegmentationExportScope = 'all' | 'visible';
type SegmentationExportMode = 'combined' | 'separate';
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
interface ModuleStyleRecord {
visible: boolean;
color: string;
opacity: number;
partId: number;
}
interface ModelPoseValue {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
}
interface ModelPoseRecord {
id: string;
name: string;
pose: ModelPoseValue;
}
interface SegmentationResultRecord {
id: string;
schemaVersion?: number;
name: string;
createdAt: string;
segmentationScope: SegmentationExportScope;
pose: ModelPoseValue;
moduleStyles: Record<string, ModuleStyleRecord>;
sliceStart: number;
sliceEnd: number;
mappingSlice: number;
displayLevel: SegmentationDisplayLevel;
dicomOpacityLevel: SegmentationDicomOpacityLevel;
showBounds: boolean;
cutEnabled: boolean;
}
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>;
modelPoses: ModelPoseRecord[];
segmentationResults: SegmentationResultRecord[];
}
interface SessionRecord {
authenticated: boolean;
account: string | null;
lastUpdated: string;
}
interface AppState {
users: UserRecord[];
projects: ProjectRecord[];
session: SessionRecord;
updatedAt: string;
}
interface UploadedAssetPayload {
name: string;
data: 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 uploadDir = path.join(dataDir, 'uploads');
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<string, {
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'];
const defaultModelPose: ModelPoseValue = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
};
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 timestampForFilename(date = new Date()) {
const parts = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date);
const value = (type: string) => parts.find((part) => part.type === type)?.value ?? '00';
return `${value('year')}-${value('month')}-${value('day')}-${value('hour')}-${value('minute')}-${value('second')}`;
}
function sanitizeFilenamePart(input: string, fallback: string) {
const cleaned = input
.trim()
.replace(/[\\/:*?"<>|]+/g, '_')
.replace(/\s+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
return cleaned || fallback;
}
function contentDispositionAttachment(filename: string) {
const asciiFallback = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}
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 toRepoRelativePath(dir: string) {
return path.relative(repoRoot, dir).split(path.sep).join('/');
}
function resolveStoredAssetDir(storedPath: string | undefined, fallbackDir: string) {
if (!storedPath) {
return fallbackDir;
}
return path.isAbsolute(storedPath) ? storedPath : path.resolve(repoRoot, storedPath);
}
function getProjectDicomDir(project: ProjectRecord) {
return resolveStoredAssetDir(project.dicomPath, project.id === 'head-ct-demo' ? dicomDir : '');
}
function getProjectModelDir(project: ProjectRecord) {
return resolveStoredAssetDir(project.modelPath, project.id === 'head-ct-demo' ? modelDir : '');
}
function getProjectDicomFilePath(project: ProjectRecord, fileName: string) {
return path.join(getProjectDicomDir(project), fileName);
}
function getProjectModelFilePath(project: ProjectRecord, fileName: string) {
return path.join(getProjectModelDir(project), fileName);
}
function clearProjectRuntimeCaches(projectId: string) {
[...dicomPreviewCache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
dicomPreviewCache.delete(key);
}
});
[...dicomVolumeCache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
dicomVolumeCache.delete(key);
}
});
modelPreviewCache.clear();
}
function publicUser(user: UserRecord) {
const { password: _password, ...rest } = user;
return rest;
}
function parseUserPayload(body: unknown, existing?: UserRecord) {
const source = body && typeof body === 'object' ? body as Record<string, unknown> : {};
const name = typeof source.name === 'string' ? source.name.trim() : existing?.name ?? '';
const account = typeof source.account === 'string' ? source.account.trim() : existing?.account ?? '';
const department = typeof source.department === 'string' ? source.department.trim() : existing?.department ?? '';
const password = typeof source.password === 'string' ? source.password : existing?.password ?? '';
return { name, account, department, password };
}
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 defaultModelPoses(): ModelPoseRecord[] {
return [
{ id: 'default', name: '默认', pose: { ...defaultModelPose } },
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
];
}
function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): ModelPoseValue {
const read = (key: keyof ModelPoseValue, fallback: number, min: number, max: number) => {
const nextValue = value?.[key];
return typeof nextValue === 'number' && Number.isFinite(nextValue)
? clampNumber(nextValue, min, max)
: fallback;
};
return {
rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180),
rotateY: read('rotateY', defaultModelPose.rotateY, -180, 180),
rotateZ: read('rotateZ', defaultModelPose.rotateZ, -180, 180),
translateX: read('translateX', defaultModelPose.translateX, -2, 2),
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
scale: read('scale', defaultModelPose.scale, 0.5, 2),
};
}
function normalizeModelPoseRecord(record: Partial<ModelPoseRecord> | undefined, fallback: ModelPoseRecord): ModelPoseRecord {
const id = typeof record?.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 80) : fallback.id;
const name = typeof record?.name === 'string' && record.name.trim() ? record.name.trim().slice(0, 80) : fallback.name;
return {
id,
name,
pose: normalizeModelPoseValue(record?.pose ?? fallback.pose),
};
}
function normalizeModelPoses(existing?: Partial<ModelPoseRecord>[]) {
const defaults = defaultModelPoses();
const incoming = Array.isArray(existing)
? existing
.map((record, index) => normalizeModelPoseRecord(record, defaults[index] ?? {
id: `pose-${index}`,
name: `位姿${index + 1}`,
pose: defaultModelPose,
}))
.filter((record) => record.id !== 'best' && record.name !== '最佳位姿')
.filter((record) => record.id)
: [];
const incomingById = new Map(incoming.map((record) => [record.id, record]));
const normalizedDefaults = defaults.map((pose) => incomingById.get(pose.id) ?? pose);
const custom = incoming.filter((record) => !defaults.some((pose) => pose.id === record.id));
return [...normalizedDefaults, ...custom];
}
function normalizeSegmentationResults(
existing: Partial<SegmentationResultRecord>[] | undefined,
stlFiles: string[],
currentModuleStyles: Record<string, ModuleStyleRecord>,
dicomCount = 0,
) {
if (!Array.isArray(existing)) {
return [];
}
const maxSlice = Math.max(dicomCount - 1, 0);
const normalizeSlice = (value: unknown, fallback: number) => (
typeof value === 'number' && Number.isFinite(value)
? clampNumber(Math.round(value), 0, maxSlice)
: clampNumber(fallback, 0, maxSlice)
);
const normalizeDisplayLevel = (value: unknown): SegmentationDisplayLevel => (
value === 'fine' || value === 'ultra' || value === 'solid' ? value : 'standard'
);
const normalizeDicomOpacityLevel = (value: unknown): SegmentationDicomOpacityLevel => (
value === 'medium' || value === 'high' ? value : 'low'
);
return existing
.filter((record) => record?.schemaVersion === 2)
.map((record, index): SegmentationResultRecord => {
const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles)
? record.moduleStyles
: currentModuleStyles;
const sliceStart = normalizeSlice(record?.sliceStart, 0);
const sliceEnd = normalizeSlice(record?.sliceEnd, maxSlice);
return {
id: typeof record?.id === 'string' && record.id.trim()
? record.id.trim().slice(0, 80)
: `segmentation-${index}`,
schemaVersion: 2,
name: '逆向分割结果',
createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(),
segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible',
pose: normalizeModelPoseValue(record?.pose),
moduleStyles: buildModuleStyles(stlFiles, rawStyles),
sliceStart,
sliceEnd,
mappingSlice: normalizeSlice(record?.mappingSlice, sliceEnd),
displayLevel: normalizeDisplayLevel(record?.displayLevel),
dicomOpacityLevel: normalizeDicomOpacityLevel(record?.dicomOpacityLevel),
showBounds: typeof record?.showBounds === 'boolean' ? record.showBounds : true,
cutEnabled: typeof record?.cutEnabled === 'boolean' ? record.cutEnabled : false,
};
})
.slice(-1);
}
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),
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
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: {},
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
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) => {
const dicomPath = typeof project.dicomPath === 'string' ? project.dicomPath : '';
const modelPath = typeof project.modelPath === 'string' ? project.modelPath : '';
const dicomCount = dicomPath ? listFiles(resolveStoredAssetDir(dicomPath, ''), '.dcm').length : project.dicomCount ?? 0;
const stlFiles = modelPath
? listFiles(resolveStoredAssetDir(modelPath, ''), '.stl')
: Array.isArray(project.stlFiles) ? project.stlFiles : [];
const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
return {
...project,
dicomPath,
modelPath,
dicomCount,
stlFiles,
hasModel: stlFiles.length > 0,
modelCount: stlFiles.length,
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles,
modelPoses: normalizeModelPoses(project.modelPoses),
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount),
};
})
: [];
const defaultDicomPath = savedDefaultProject?.dicomPath ?? defaultProject.dicomPath;
const defaultModelPath = savedDefaultProject?.modelPath ?? defaultProject.modelPath;
const defaultDicomCount = listFiles(resolveStoredAssetDir(defaultDicomPath, dicomDir), '.dcm').length;
const defaultStlFiles = listFiles(resolveStoredAssetDir(defaultModelPath, modelDir), '.stl');
const defaultModuleStyles = buildModuleStyles(defaultStlFiles, savedDefaultProject?.moduleStyles);
return {
...state,
projects: [
{
...defaultProject,
name: savedDefaultProject?.name ?? defaultProject.name,
dicomPath: defaultDicomPath,
modelPath: defaultModelPath,
dicomCount: defaultDicomCount,
hasModel: defaultStlFiles.length > 0,
modelCount: defaultStlFiles.length,
stlFiles: defaultStlFiles,
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: defaultModuleStyles,
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
segmentationResults: normalizeSegmentationResults(
savedDefaultProject?.segmentationResults,
defaultStlFiles,
defaultModuleStyles,
defaultDicomCount,
),
},
...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));
}
interface DicomHuVolume {
width: number;
height: number;
depth: number;
columnSpacing: number;
rowSpacing: number;
sliceSpacing: number;
data: Buffer;
minHu: number;
maxHu: number;
}
interface Point2DRecord {
x: number;
y: number;
}
interface Point3DRecord {
x: number;
y: number;
z: number;
}
interface PlaneSegmentRecord {
a: Point2DRecord;
b: Point2DRecord;
}
interface ModelBoundsRecord {
min: Point3DRecord;
max: Point3DRecord;
}
interface ModelPreviewRecord {
fileName: string;
triangleCount: number;
sampledTriangles: number;
vertices: number[];
bounds: ModelBoundsRecord;
}
interface ExportSceneMetrics {
center: Point3DRecord;
modelBaseScale: number;
modelPivotOffsetZ: number;
dicomWidth: number;
dicomHeight: number;
dicomDepth: number;
}
const exportFusionBaseExtent = 4.6;
function readDicomHuVolume(project: ProjectRecord, files: string[]): DicomHuVolume {
if (!files.length) {
throw new Error('当前项目没有可导出的 DICOM 序列');
}
const parsed = files.map((fileName) => {
const buffer = fs.readFileSync(getProjectDicomFilePath(project, fileName));
const attributes = parseDicomAttributes(buffer, 'default');
const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
if (!attributes.rows || !attributes.columns || !pixelTag) {
throw new Error(`无法解析 DICOM 像素数据:${fileName}`);
}
return { fileName, buffer, attributes, pixelOffset: pixelTag.valueOffset, pixelLength: pixelTag.length };
});
const first = parsed[0];
const width = first.attributes.columns;
const height = first.attributes.rows;
const depth = parsed.length;
const data = Buffer.alloc(width * height * depth * 2);
let minHu = Infinity;
let maxHu = -Infinity;
parsed.forEach((slice, z) => {
if (slice.attributes.columns !== width || slice.attributes.rows !== height) {
throw new Error(`DICOM 尺寸不一致:${slice.fileName}`);
}
for (let index = 0; index < width * height; index += 1) {
const position = slice.pixelOffset + index * (slice.attributes.bitsAllocated / 8);
if (position + 1 >= slice.buffer.length || position >= slice.pixelOffset + slice.pixelLength) {
continue;
}
const raw = slice.attributes.bitsAllocated === 16
? (slice.attributes.pixelRepresentation ? slice.buffer.readInt16LE(position) : slice.buffer.readUInt16LE(position))
: slice.buffer.readUInt8(position);
const hu = clampNumber(Math.round(raw * slice.attributes.rescaleSlope + slice.attributes.rescaleIntercept), -32768, 32767);
const outputOffset = (z * width * height + index) * 2;
data.writeInt16LE(hu, outputOffset);
minHu = Math.min(minHu, hu);
maxHu = Math.max(maxHu, hu);
}
});
const sliceSpacing = estimateSliceSpacingFromAttributes(parsed.map((item) => item.attributes)).value;
return {
width,
height,
depth,
columnSpacing: first.attributes.columnSpacing,
rowSpacing: first.attributes.rowSpacing,
sliceSpacing,
data,
minHu: Number.isFinite(minHu) ? minHu : 0,
maxHu: Number.isFinite(maxHu) ? maxHu : 0,
};
}
function writeNiftiHeader({
width,
height,
depth,
columnSpacing,
rowSpacing,
sliceSpacing,
datatype,
bitpix,
description,
auxFile,
}: {
width: number;
height: number;
depth: number;
columnSpacing: number;
rowSpacing: number;
sliceSpacing: number;
datatype: number;
bitpix: number;
description: string;
auxFile: string;
}) {
const voxOffset = 352;
const header = Buffer.alloc(voxOffset);
header.writeInt32LE(348, 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(datatype, 70);
header.writeInt16LE(bitpix, 72);
header.writeFloatLE(1, 76);
header.writeFloatLE(columnSpacing, 80);
header.writeFloatLE(rowSpacing, 84);
header.writeFloatLE(sliceSpacing, 88);
header.writeFloatLE(voxOffset, 108);
header.writeFloatLE(1, 112);
header.writeUInt8(2, 123);
header.writeInt16LE(1, 252);
header.writeInt16LE(1, 254);
header.write(description.slice(0, 79), 148, 'ascii');
header.write(auxFile.slice(0, 23), 228, 'ascii');
header.writeFloatLE(columnSpacing, 280);
header.writeFloatLE(0, 284);
header.writeFloatLE(0, 288);
header.writeFloatLE(0, 292);
header.writeFloatLE(0, 296);
header.writeFloatLE(rowSpacing, 300);
header.writeFloatLE(0, 304);
header.writeFloatLE(0, 308);
header.writeFloatLE(0, 312);
header.writeFloatLE(0, 316);
header.writeFloatLE(sliceSpacing, 320);
header.writeFloatLE(0, 324);
header.write('n+1\0', 344, 'ascii');
return header;
}
function createNiftiBuffer(volume: DicomHuVolume, data: Buffer, kind: 'dicom' | 'segmentation', compressed: boolean) {
const isSegmentation = kind === 'segmentation';
const nifti = Buffer.concat([
writeNiftiHeader({
width: volume.width,
height: volume.height,
depth: volume.depth,
columnSpacing: volume.columnSpacing,
rowSpacing: volume.rowSpacing,
sliceSpacing: volume.sliceSpacing,
datatype: isSegmentation ? 2 : 4,
bitpix: isSegmentation ? 8 : 16,
description: isSegmentation ? 'ReVoxelSeg label map' : 'ReVoxelSeg DICOM HU volume',
auxFile: isSegmentation ? 'segmentation' : 'dicom',
}),
data,
]);
return compressed ? zlib.gzipSync(nifti) : nifti;
}
function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, previews: Record<string, ModelPreviewRecord>): ExportSceneMetrics | null {
const bounds = (project.stlFiles ?? []).reduce<ModelBoundsRecord>((accumulator, fileName) => {
const payload = previews[fileName];
if (!payload) {
return accumulator;
}
accumulator.min.x = Math.min(accumulator.min.x, payload.bounds.min.x);
accumulator.min.y = Math.min(accumulator.min.y, payload.bounds.min.y);
accumulator.min.z = Math.min(accumulator.min.z, payload.bounds.min.z);
accumulator.max.x = Math.max(accumulator.max.x, payload.bounds.max.x);
accumulator.max.y = Math.max(accumulator.max.y, payload.bounds.max.y);
accumulator.max.z = Math.max(accumulator.max.z, payload.bounds.max.z);
return accumulator;
}, {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
});
if (!Number.isFinite(bounds.min.x)) {
return null;
}
const spanX = Math.max(bounds.max.x - bounds.min.x, 0.001);
const spanY = Math.max(bounds.max.y - bounds.min.y, 0.001);
const spanZ = Math.max(bounds.max.z - bounds.min.z, 0.001);
const maxModelSize = Math.max(spanX, spanY, spanZ, 1);
const physicalWidth = volume.width * volume.columnSpacing;
const physicalHeight = volume.height * volume.rowSpacing;
const physicalDepth = Math.max(volume.depth, 1) * volume.sliceSpacing;
const maxPhysical = Math.max(physicalWidth, physicalHeight, physicalDepth, 1);
const dicomWidth = (physicalWidth / maxPhysical) * exportFusionBaseExtent;
const dicomHeight = (physicalHeight / maxPhysical) * exportFusionBaseExtent;
const dicomDepth = Math.max((physicalDepth / maxPhysical) * exportFusionBaseExtent, 0.18);
const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
return {
center: {
x: (bounds.min.x + bounds.max.x) / 2,
y: (bounds.min.y + bounds.max.y) / 2,
z: (bounds.min.z + bounds.max.z) / 2,
},
modelBaseScale,
modelPivotOffsetZ: dicomDepth * 0.08,
dicomWidth,
dicomHeight,
dicomDepth,
};
}
function transformPointForExportPose(x: number, y: number, z: number, metrics: ExportSceneMetrics, pose: ModelPoseValue): Point3DRecord {
const scalar = metrics.modelBaseScale * pose.scale;
let px = (x - metrics.center.x) * scalar;
let py = (y - metrics.center.y) * scalar;
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
const rotateX = (pose.rotateX * Math.PI) / 180;
const rotateY = (pose.rotateY * Math.PI) / 180;
const rotateZ = (pose.rotateZ * Math.PI) / 180;
const cosX = Math.cos(rotateX);
const sinX = Math.sin(rotateX);
const cosY = Math.cos(rotateY);
const sinY = Math.sin(rotateY);
const cosZ = Math.cos(rotateZ);
const sinZ = Math.sin(rotateZ);
const afterX = {
x: px,
y: py * cosX - pz * sinX,
z: py * sinX + pz * cosX,
};
const afterY = {
x: afterX.x * cosY + afterX.z * sinY,
y: afterX.y,
z: -afterX.x * sinY + afterX.z * cosY,
};
px = afterY.x * cosZ - afterY.y * sinZ;
py = afterY.x * sinZ + afterY.y * cosZ;
pz = afterY.z;
return {
x: px + pose.translateX,
y: py + pose.translateY,
z: pz + pose.translateZ,
};
}
function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null {
const epsilon = 1e-5;
const startDistance = start.z - targetZ;
const endDistance = end.z - targetZ;
if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) {
return null;
}
if (Math.abs(startDistance) <= epsilon) {
return { x: start.x, y: start.y };
}
if (Math.abs(endDistance) <= epsilon) {
return { x: end.x, y: end.y };
}
if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) {
return null;
}
const t = startDistance / (startDistance - endDistance);
return {
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t,
};
}
function exportPointDistanceSquared(a: Point2DRecord, b: Point2DRecord) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
}
function intersectExportTriangleWithPlane(a: Point3DRecord, b: Point3DRecord, c: Point3DRecord, targetZ: number): PlaneSegmentRecord | null {
const intersections = [
intersectExportEdgeWithPlane(a, b, targetZ),
intersectExportEdgeWithPlane(b, c, targetZ),
intersectExportEdgeWithPlane(c, a, targetZ),
].filter((point): point is Point2DRecord => Boolean(point));
const uniquePoints: Point2DRecord[] = [];
intersections.forEach((point) => {
if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-8)) {
uniquePoints.push(point);
}
});
if (uniquePoints.length < 2) {
return null;
}
let segment: PlaneSegmentRecord = { a: uniquePoints[0], b: uniquePoints[1] };
let maxDistance = exportPointDistanceSquared(segment.a, segment.b);
for (let first = 0; first < uniquePoints.length; first += 1) {
for (let second = first + 1; second < uniquePoints.length; second += 1) {
const distance = exportPointDistanceSquared(uniquePoints[first], uniquePoints[second]);
if (distance > maxDistance) {
maxDistance = distance;
segment = { a: uniquePoints[first], b: uniquePoints[second] };
}
}
}
return maxDistance > 1e-8 ? segment : null;
}
function readBinaryStlTriangleCount(buffer: Buffer, fileName: string) {
if (buffer.length < 84) {
throw new Error(`STL 文件内容为空或不完整:${fileName}`);
}
const triangleCount = buffer.readUInt32LE(80);
const expectedLength = 84 + triangleCount * 50;
if (triangleCount <= 0 || expectedLength > buffer.length + 1024) {
throw new Error(`当前仅支持二进制 STL${fileName}`);
}
return triangleCount;
}
function forEachBinaryStlTriangle(
filePath: string,
fileName: string,
callback: (
ax: number,
ay: number,
az: number,
bx: number,
by: number,
bz: number,
cx: number,
cy: number,
cz: number,
) => void,
) {
const buffer = fs.readFileSync(filePath);
const triangleCount = readBinaryStlTriangleCount(buffer, fileName);
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
const offset = 84 + triangleIndex * 50;
if (offset + 50 > buffer.length) {
break;
}
callback(
buffer.readFloatLE(offset + 12),
buffer.readFloatLE(offset + 16),
buffer.readFloatLE(offset + 20),
buffer.readFloatLE(offset + 24),
buffer.readFloatLE(offset + 28),
buffer.readFloatLE(offset + 32),
buffer.readFloatLE(offset + 36),
buffer.readFloatLE(offset + 40),
buffer.readFloatLE(offset + 44),
);
}
}
function addExportSegmentToRows(rows: number[][], width: number, height: number, segment: PlaneSegmentRecord) {
const deltaY = segment.b.y - segment.a.y;
if (Math.abs(deltaY) < 0.01) {
return;
}
const minY = Math.max(0, Math.floor(Math.min(segment.a.y, segment.b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(segment.a.y, segment.b.y)));
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const crosses = (sampleY >= segment.a.y && sampleY < segment.b.y)
|| (sampleY >= segment.b.y && sampleY < segment.a.y);
if (!crosses) {
continue;
}
const t = (sampleY - segment.a.y) / deltaY;
const x = segment.a.x + (segment.b.x - segment.a.x) * t;
if (Number.isFinite(x)) {
rows[row].push(x);
}
}
}
function fillExportInternalHoles(mask: Uint8Array, width: number, height: number) {
const outside = new Uint8Array(width * height);
const stack: number[] = [];
const pushIfEmpty = (x: number, y: number) => {
if (x < 0 || x >= width || y < 0 || y >= height) {
return;
}
const index = y * width + x;
if (outside[index] || mask[index]) {
return;
}
outside[index] = 1;
stack.push(index);
};
for (let x = 0; x < width; x += 1) {
pushIfEmpty(x, 0);
pushIfEmpty(x, height - 1);
}
for (let y = 0; y < height; y += 1) {
pushIfEmpty(0, y);
pushIfEmpty(width - 1, y);
}
while (stack.length) {
const index = stack.pop();
if (index === undefined) {
continue;
}
const x = index % width;
const y = Math.floor(index / width);
pushIfEmpty(x + 1, y);
pushIfEmpty(x - 1, y);
pushIfEmpty(x, y + 1);
pushIfEmpty(x, y - 1);
}
let patchedPixels = 0;
for (let index = 0; index < mask.length; index += 1) {
if (!outside[index] && !mask[index]) {
mask[index] = 1;
patchedPixels += 1;
}
}
return patchedPixels;
}
function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) {
const mask = new Uint8Array(width * height);
let filledPixels = 0;
rows.forEach((intersections, row) => {
if (intersections.length < 2) {
return;
}
intersections.sort((left, right) => left - right);
const cleaned: number[] = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) {
cleaned.push(x);
}
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const rawStartX = cleaned[index];
const rawEndX = cleaned[index + 1];
if (rawEndX < 0 || rawStartX > width - 1) {
continue;
}
const startX = clampNumber(Math.ceil(rawStartX), 0, width - 1);
const endX = clampNumber(Math.floor(rawEndX), 0, width - 1);
for (let x = startX; x <= endX; x += 1) {
const index = row * width + x;
if (!mask[index]) {
mask[index] = 1;
filledPixels += 1;
}
}
}
});
if (filledPixels === 0) {
return 0;
}
filledPixels += fillExportInternalHoles(mask, width, height);
const sliceOffset = slice * width * height;
for (let index = 0; index < mask.length; index += 1) {
if (mask[index]) {
data[sliceOffset + index] = label;
}
}
return filledPixels;
}
function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord {
return project.moduleStyles[fileName] ?? {
visible: true,
color: defaultModuleColors[index % defaultModuleColors.length],
opacity: 0.72,
partId: index + 1,
};
}
function isModuleIncludedForExport(style: ModuleStyleRecord, scope: SegmentationExportScope) {
return scope === 'all' || style.visible !== false;
}
function createSegmentationData(
project: ProjectRecord,
volume: DicomHuVolume,
pose: ModelPoseValue,
scope: SegmentationExportScope = 'visible',
onlyFileName?: string,
) {
const data = Buffer.alloc(volume.width * volume.height * volume.depth);
const previews = (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
const filePath = getProjectModelFilePath(project, fileName);
if (fs.existsSync(filePath)) {
accumulator[fileName] = createStlPreview(filePath, fileName, 200000) as ModelPreviewRecord;
}
return accumulator;
}, {});
const metrics = getExportMetrics(project, volume, previews);
if (!metrics) {
return data;
}
const sliceToZ = (slice: number) => (
volume.depth <= 1
? 0
: -metrics.dicomDepth / 2 + (metrics.dicomDepth * slice) / (volume.depth - 1)
);
const zToSlice = (z: number) => (
volume.depth <= 1
? 0
: ((z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1)
);
const mapPoint = (point: Point2DRecord): Point2DRecord => ({
x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width,
y: volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height,
});
(project.stlFiles ?? []).forEach((fileName, index) => {
const payload = previews[fileName];
const style = getModuleStyle(project, fileName, index);
if (!payload || (onlyFileName && fileName !== onlyFileName) || !isModuleIncludedForExport(style, scope)) {
return;
}
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
const rowsBySlice = new Map<number, number[][]>();
const rowsForSlice = (slice: number) => {
const existing = rowsBySlice.get(slice);
if (existing) {
return existing;
}
const rows = Array.from({ length: volume.height }, () => [] as number[]);
rowsBySlice.set(slice, rows);
return rows;
};
const filePath = getProjectModelFilePath(project, fileName);
forEachBinaryStlTriangle(filePath, fileName, (
ax,
ay,
az,
bx,
by,
bz,
cx,
cy,
cz,
) => {
const a = transformPointForExportPose(
ax,
ay,
az,
metrics,
pose,
);
const b = transformPointForExportPose(
bx,
by,
bz,
metrics,
pose,
);
const c = transformPointForExportPose(
cx,
cy,
cz,
metrics,
pose,
);
const minSlice = clampNumber(Math.floor(zToSlice(Math.min(a.z, b.z, c.z))) - 1, 0, volume.depth - 1);
const maxSlice = clampNumber(Math.ceil(zToSlice(Math.max(a.z, b.z, c.z))) + 1, 0, volume.depth - 1);
for (let slice = minSlice; slice <= maxSlice; slice += 1) {
const segment = intersectExportTriangleWithPlane(a, b, c, sliceToZ(slice));
if (!segment) {
continue;
}
addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, {
a: mapPoint(segment.a),
b: mapPoint(segment.b),
});
}
});
rowsBySlice.forEach((rows, slice) => {
fillExportRows(data, volume.width, volume.height, slice, rows, label);
});
});
return data;
}
function createSegmentationLabelMetadata(project: ProjectRecord, scope: SegmentationExportScope, activePose?: ModelPoseValue) {
const labels = (project.stlFiles ?? [])
.map((fileName, index) => {
const style = getModuleStyle(project, fileName, index);
if (!isModuleIncludedForExport(style, scope)) {
return null;
}
const name = fileName.replace(/\.stl$/i, '');
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
return {
label,
partId: label,
name,
categoryName: name,
className: name,
fileName,
color: style.color,
opacity: style.opacity,
visible: style.visible !== false,
};
})
.filter((item): item is {
label: number;
partId: number;
name: string;
categoryName: string;
className: string;
fileName: string;
color: string;
opacity: number;
visible: boolean;
} => Boolean(item));
return Buffer.from(JSON.stringify({
project: {
id: project.id,
name: project.name,
dicomPath: project.dicomPath,
modelPath: project.modelPath,
},
generatedAt: now(),
segmentationScope: scope,
activePose: activePose ?? null,
labels,
note: 'Label values correspond to ReVoxelSeg STL component hierarchy partId values.',
}, null, 2), 'utf8');
}
function parseModelPoseQuery(raw: unknown) {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
try {
return normalizeModelPoseValue(JSON.parse(raw) as Partial<ModelPoseValue>);
} catch {
return undefined;
}
}
function parseSegmentationScope(raw: unknown): SegmentationExportScope {
return raw === 'all' ? 'all' : 'visible';
}
function parseSegmentationExportMode(raw: unknown): SegmentationExportMode {
return raw === 'separate' ? 'separate' : 'combined';
}
function latestSegmentationResult(project: ProjectRecord) {
return project.segmentationResults?.[project.segmentationResults.length - 1];
}
function projectWithSegmentationResultStyles(project: ProjectRecord): ProjectRecord {
const latestResult = latestSegmentationResult(project);
if (!latestResult) {
return project;
}
return {
...project,
moduleStyles: latestResult.moduleStyles,
};
}
function parseExportTargets(raw: unknown): ProjectExportTarget[] {
const values = typeof raw === 'string' ? raw.split(',') : [];
const targets = values.filter((value): value is ProjectExportTarget => (
value === 'dicom' || value === 'segmentation' || value === 'pose' || value === 'stl'
));
return [...new Set(targets)];
}
function sanitizeUploadFileName(name: string, fallback: string, extension: string) {
const baseName = path.basename(name || fallback).replace(/[\\/:*?"<>|]+/g, '_').trim();
const withFallback = baseName || fallback;
return withFallback.toLowerCase().endsWith(extension) ? withFallback : `${withFallback}${extension}`;
}
function decodeUploadedAssetData(raw: string) {
const base64 = raw.includes(',') ? raw.slice(raw.indexOf(',') + 1) : raw;
if (!base64.trim()) {
throw new Error('上传文件内容为空');
}
return Buffer.from(base64, 'base64');
}
function parseUploadedAssets(raw: unknown): UploadedAssetPayload[] {
if (!Array.isArray(raw)) {
throw new Error('上传文件列表无效');
}
return raw
.map((item) => item && typeof item === 'object' ? item as Record<string, unknown> : null)
.filter((item): item is Record<string, unknown> => Boolean(item))
.map((item, index) => ({
name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : `asset-${index + 1}`,
data: typeof item.data === 'string' ? item.data : '',
}))
.filter((item) => item.data);
}
function createNiftiExport(
project: ProjectRecord,
files: string[],
target: 'dicom' | 'segmentation',
compressed: boolean,
pose?: ModelPoseValue,
segmentationScope: SegmentationExportScope = 'visible',
) {
const volume = readDicomHuVolume(project, files);
if (target === 'dicom') {
return createNiftiBuffer(volume, volume.data, 'dicom', compressed);
}
return createNiftiBuffer(
volume,
createSegmentationData(project, volume, pose ?? defaultModelPose, segmentationScope),
'segmentation',
compressed,
);
}
function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
return Buffer.from(JSON.stringify({
project: {
id: project.id,
name: project.name,
dicomPath: project.dicomPath,
modelPath: project.modelPath,
},
generatedAt: now(),
activePose: activePose ?? null,
modelPoses: project.modelPoses,
moduleStyles: project.moduleStyles,
note: 'Pose values are stored in the ReVoxelSeg fusion view coordinate system.',
}, null, 2), 'utf8');
}
function createProjectExportBundle({
project,
files,
targets,
compressed,
activePose,
segmentationScope,
segmentationExportMode,
exportRoot,
}: {
project: ProjectRecord;
files: string[];
targets: ProjectExportTarget[];
compressed: boolean;
activePose?: ModelPoseValue;
segmentationScope: SegmentationExportScope;
segmentationExportMode: SegmentationExportMode;
exportRoot: string;
}) {
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
const needsVolume = targets.includes('dicom') || targets.includes('segmentation');
const volume = needsVolume ? readDicomHuVolume(project, files) : null;
const format = compressed ? 'nii.gz' : 'nii';
if (targets.includes('dicom') && volume) {
entries.push({
name: `${exportRoot}/${project.id}-dicom-image.${format}`,
data: createNiftiBuffer(volume, volume.data, 'dicom', compressed),
});
}
if (targets.includes('segmentation') && volume) {
if (segmentationExportMode === 'separate') {
(project.stlFiles ?? []).forEach((fileName, index) => {
const style = getModuleStyle(project, fileName, index);
if (!isModuleIncludedForExport(style, segmentationScope)) {
return;
}
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
entries.push({
name: `${exportRoot}/segmentation/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
data: createNiftiBuffer(
volume,
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
'segmentation',
compressed,
),
});
});
} else {
entries.push({
name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
data: createNiftiBuffer(
volume,
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope),
'segmentation',
compressed,
),
});
}
entries.push({
name: `${exportRoot}/${project.id}-segmentation-labels.json`,
data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
});
}
if (targets.includes('pose')) {
entries.push({
name: `${exportRoot}/${project.id}-pose-data.json`,
data: createPoseExport(project, activePose),
});
}
if (targets.includes('stl')) {
(project.stlFiles ?? []).forEach((fileName) => {
const filePath = getProjectModelFilePath(project, fileName);
if (!fs.existsSync(filePath)) {
return;
}
const stat = fs.statSync(filePath);
entries.push({
name: `${exportRoot}/STL/${fileName}`,
data: fs.readFileSync(filePath),
mtime: stat.mtimeMs / 1000,
});
});
}
if (!entries.length) {
throw new Error('未选择可导出的内容');
}
return createTarGz(entries);
}
function findProject(state: AppState, projectId: string) {
return state.projects.find((candidate) => candidate.id === projectId);
}
function getProjectDicomFiles(project: ProjectRecord) {
return listFiles(getProjectDicomDir(project), '.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(project: ProjectRecord, files: string[], mode: DicomDisplayMode) {
const cacheKey = `${project.id}:${project.dicomPath}:${mode}:${files.join('|')}`;
const cached = dicomVolumeCache.get(cacheKey);
if (cached) {
return cached;
}
const parsed = files.map((fileName) => parseDicomPixels(getProjectDicomFilePath(project, 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(cacheKey, 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(project: ProjectRecord, files: string[]) {
setTimeout(() => {
try {
getDicomVolume(project, files, 'default');
getDicomVolume(project, files, 'soft');
} catch (error) {
console.warn('DICOM volume warmup failed:', error);
}
}, 300);
}
function createReformattedPreview(project: ProjectRecord, files: string[], plane: Exclude<DicomPlane, 'axial'>, slice: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(project, 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(project: ProjectRecord, files: string[], start: number, end: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(project, 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): ModelPreviewRecord {
const cacheKey = `${filePath}:${fileName}:${limit}`;
const cached = modelPreviewCache.get(cacheKey);
if (cached) {
return cached as ModelPreviewRecord;
}
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: ModelPreviewRecord = {
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 createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) {
const chunks: Buffer[] = [];
entries.forEach((entry) => {
const data = entry.data;
chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 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 createDicomTarGz(project: ProjectRecord, files: string[]) {
const rootName = sanitizeFilenamePart(project.dicomPath || 'DICOM', 'DICOM');
return createTarGz(files.map((fileName) => {
const filePath = getProjectDicomFilePath(project, fileName);
const stat = fs.statSync(filePath);
return {
name: `${rootName}/${fileName}`,
data: fs.readFileSync(filePath),
mtime: stat.mtimeMs / 1000,
};
}));
}
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(getProjectDicomFilePath(project, 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({ limit: '512mb' }));
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.post('/api/users', (req, res) => {
const state = readState();
const payload = parseUserPayload(req.body);
if (!payload.name || !payload.account || !payload.department || !payload.password) {
res.status(400).json({ message: '姓名、账号、科室和密码不能为空' });
return;
}
if (state.users.some((user) => user.account === payload.account)) {
res.status(409).json({ message: '账号已存在' });
return;
}
const nextId = Math.max(0, ...state.users.map((user) => user.id)) + 1;
const user: UserRecord = {
id: nextId,
name: payload.name,
account: payload.account,
password: payload.password,
department: payload.department,
date: today(),
};
state.users.push(user);
writeState(state);
res.status(201).json(publicUser(user));
});
app.patch('/api/users/:userId', (req, res) => {
const state = readState();
const userId = Number.parseInt(req.params.userId, 10);
const user = state.users.find((candidate) => candidate.id === userId);
if (!user) {
res.status(404).json({ message: '用户不存在' });
return;
}
const payload = parseUserPayload(req.body, user);
if (!payload.name || !payload.account || !payload.department || !payload.password) {
res.status(400).json({ message: '姓名、账号、科室和密码不能为空' });
return;
}
if (state.users.some((candidate) => candidate.id !== user.id && candidate.account === payload.account)) {
res.status(409).json({ message: '账号已存在' });
return;
}
const previousAccount = user.account;
user.name = payload.name;
user.account = payload.account;
user.department = payload.department;
user.password = payload.password;
if (state.session.account === previousAccount) {
state.session = { authenticated: true, account: user.account, lastUpdated: now() };
}
writeState(state);
res.json(publicUser(user));
});
app.delete('/api/users/:userId', (req, res) => {
const state = readState();
const userId = Number.parseInt(req.params.userId, 10);
const index = state.users.findIndex((candidate) => candidate.id === userId);
if (index === -1) {
res.status(404).json({ message: '用户不存在' });
return;
}
const user = state.users[index];
if (state.session.account === user.account) {
res.status(400).json({ message: '不能删除当前登录用户' });
return;
}
if (state.users.length <= 1) {
res.status(400).json({ message: '至少保留一个用户' });
return;
}
state.users.splice(index, 1);
writeState(state);
res.json({ ok: true, deletedId: user.id });
});
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.patch('/api/projects/:projectId/model-poses', (req, res) => {
const incoming = req.body?.modelPoses;
if (!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.modelPoses = normalizeModelPoses(incoming as Partial<ModelPoseRecord>[]);
writeState(state);
res.json(project);
});
app.post('/api/projects/:projectId/import-assets', (req, res) => {
const kind = req.body?.kind === 'stl' ? 'stl' : 'dicom';
let uploadedFiles: UploadedAssetPayload[];
try {
uploadedFiles = parseUploadedAssets(req.body?.files);
} catch (error) {
res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' });
return;
}
if (!uploadedFiles.length) {
res.status(400).json({ message: '请选择需要导入的文件' });
return;
}
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL');
try {
fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir);
uploadedFiles.forEach((file, index) => {
const fileName = sanitizeUploadFileName(
file.name,
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
kind === 'dicom' ? '.dcm' : '.stl',
);
fs.writeFileSync(path.join(targetDir, fileName), decodeUploadedAssetData(file.data));
});
if (kind === 'dicom') {
const dicomFiles = listFiles(targetDir, '.dcm');
project.dicomPath = toRepoRelativePath(targetDir);
project.dicomCount = dicomFiles.length;
project.segmentationResults = [];
} else {
const stlFiles = listFiles(targetDir, '.stl');
project.modelPath = toRepoRelativePath(targetDir);
project.stlFiles = stlFiles;
project.modelCount = stlFiles.length;
project.hasModel = stlFiles.length > 0;
project.moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
project.segmentationResults = [];
}
project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending';
clearProjectRuntimeCaches(project.id);
writeState(state);
res.json(project);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' });
}
});
app.post('/api/projects/:projectId/segmentation-results', (req, res) => {
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const rawName = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const rawStyles = req.body?.moduleStyles && typeof req.body.moduleStyles === 'object' && !Array.isArray(req.body.moduleStyles)
? {
...project.moduleStyles,
...(req.body.moduleStyles as Record<string, Partial<ModuleStyleRecord>>),
}
: project.moduleStyles;
const record: SegmentationResultRecord = {
id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
schemaVersion: 2,
name: rawName || '逆向分割结果',
createdAt: now(),
segmentationScope: parseSegmentationScope(req.body?.segmentationScope),
pose: normalizeModelPoseValue(req.body?.pose as Partial<ModelPoseValue> | undefined),
moduleStyles: buildModuleStyles(project.stlFiles, rawStyles),
sliceStart: Number(req.body?.sliceStart),
sliceEnd: Number(req.body?.sliceEnd),
mappingSlice: Number(req.body?.mappingSlice),
displayLevel: req.body?.displayLevel as SegmentationDisplayLevel,
dicomOpacityLevel: req.body?.dicomOpacityLevel as SegmentationDicomOpacityLevel,
showBounds: typeof req.body?.showBounds === 'boolean' ? req.body.showBounds : true,
cutEnabled: typeof req.body?.cutEnabled === 'boolean' ? req.body.cutEnabled : false,
};
project.moduleStyles = record.moduleStyles;
project.segmentationResults = normalizeSegmentationResults(
[record],
project.stlFiles,
record.moduleStyles,
project.dicomCount,
);
writeState(state);
res.status(201).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(getProjectDicomFilePath(project, files[slice]), mode);
payload = {
...preview,
plane,
slice,
total: files.length,
fileName: files[slice],
};
} else {
payload = {
...createReformattedPreview(project, 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(project, 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(project, 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.stlFiles.includes(fileName)) {
res.status(404).json({ message: '模型文件不存在' });
return;
}
res.sendFile(getProjectModelFilePath(project, 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.stlFiles.includes(fileName)) {
res.status(404).json({ message: '模型文件不存在' });
return;
}
try {
res.json(createStlPreview(getProjectModelFilePath(project, 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) });
});
const handleProjectExport = (req: express.Request, res: express.Response, targetOverride?: 'segmentation') => {
const state = readState();
const project = state.projects.find((candidate) => candidate.id === req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation';
const exportProject = projectWithSegmentationResultStyles(project);
const latestResult = latestSegmentationResult(project);
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
const segmentationScope = req.query.segmentationScope === undefined
? latestResult?.segmentationScope ?? 'visible'
: parseSegmentationScope(req.query.segmentationScope);
try {
if (target === 'pose') {
const posePayload = createPoseExport(exportProject, activePose);
const filename = `${project.id}-pose-data.json`;
fs.writeFileSync(path.join(exportDir, filename), posePayload);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(posePayload);
return;
}
const files = getProjectDicomFiles(project);
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
const compressed = format === 'nii.gz';
const payload = createNiftiExport(exportProject, files, target, compressed, activePose, segmentationScope);
const suffix = target === 'dicom' ? 'dicom-image' : 'segmentation-label';
const filename = `${project.id}-${suffix}.${format}`;
fs.writeFileSync(path.join(exportDir, filename), payload);
project.exportedMaskCount += target === 'segmentation' ? 1 : 0;
writeState(state);
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(payload);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '导出失败' });
}
};
app.get('/api/projects/:projectId/export-bundle', (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 targets = parseExportTargets(req.query.targets);
if (!targets.length) {
res.status(400).json({ message: '请至少选择一个导出内容' });
return;
}
const exportProject = projectWithSegmentationResultStyles(project);
const latestResult = latestSegmentationResult(project);
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
const segmentationScope = req.query.segmentationScope === undefined
? latestResult?.segmentationScope ?? 'visible'
: parseSegmentationScope(req.query.segmentationScope);
const segmentationExportMode = parseSegmentationExportMode(req.query.segmentationExportMode);
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
const compressed = format === 'nii.gz';
try {
const files = getProjectDicomFiles(project);
const exportBase = `${sanitizeFilenamePart(project.name, project.id)}_${timestampForFilename()}`;
const payload = createProjectExportBundle({
project: exportProject,
files,
targets,
compressed,
activePose,
segmentationScope,
segmentationExportMode,
exportRoot: exportBase,
});
const filename = `${exportBase}.tar.gz`;
fs.writeFileSync(path.join(exportDir, filename), payload);
project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0;
writeState(state);
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', contentDispositionAttachment(filename));
res.send(payload);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '导出包生成失败' });
}
});
app.get('/api/projects/:projectId/export-nifti', (req, res) => handleProjectExport(req, res));
app.get('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation'));
app.post('/api/projects/:projectId/export-mask', (req, res) => handleProjectExport(req, res, 'segmentation'));
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}/`);
const defaultProject = buildDefaultProject();
warmDicomVolumeCache(defaultProject, getProjectDicomFiles(defaultProject));
});
}
startServer().catch((error) => {
console.error(error);
process.exit(1);
});