Files
REVOXELSEG_DICOM/WebSite/server.ts

3898 lines
130 KiB
TypeScript
Raw 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 AdmZip from 'adm-zip';
import multer from 'multer';
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';
type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale';
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;
flipX: boolean;
flipY: boolean;
flipZ: boolean;
}
interface ModelPoseRecord {
id: string;
name: string;
pose: ModelPoseValue;
}
interface AutoMatchParameterSelection {
translateX: boolean;
translateY: boolean;
translateZ: boolean;
scale: boolean;
}
interface AutoMatchWeights {
boneReward: number;
missPenalty: number;
movementPenalty: number;
scalePenalty: number;
}
interface AutoMatchCandidateRecord {
iteration: number;
mode: string;
pose: ModelPoseValue;
score: number;
boneReward: number;
missPenalty: number;
movementPenalty: number;
scalePenalty: number;
contributors: number;
changed: AutoMatchParameterKey[];
}
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;
locked: boolean;
lockedAt: string | null;
unlockedAt: string | null;
lastProcessedAt: string;
lockedPoseSnapshotPath: string | null;
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;
}
interface PreparedAssetFile {
name: string;
data: Buffer;
}
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 uploadTempDir = path.join(dataDir, 'upload-tmp');
const statePath = path.join(dataDir, 'state.json');
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
const lockedResultDir = path.join(repoRoot, '项目数据', '锁定结果');
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 maxPreviewTriangles = 800000;
const defaultModelPose: ModelPoseValue = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const modelPoseValuePrecision: Partial<Record<keyof ModelPoseValue, number>> = {
translateX: 3,
translateY: 3,
translateZ: 3,
scale: 3,
};
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 timestampMillis(value: unknown) {
if (typeof value !== 'string' || !value.trim()) {
return 0;
}
const time = Date.parse(value);
return Number.isFinite(time) ? time : 0;
}
function normalizeOptionalTimestamp(value: unknown) {
const time = timestampMillis(value);
return time > 0 ? new Date(time).toISOString() : null;
}
function latestProjectTimestamp(project: Partial<ProjectRecord>, fallback: string) {
const latestResult = Array.isArray(project.segmentationResults)
? project.segmentationResults[project.segmentationResults.length - 1]
: undefined;
const candidates = [
project.lastProcessedAt,
project.lockedAt,
project.unlockedAt,
latestResult?.createdAt,
project.createTime,
fallback,
];
const latest = Math.max(...candidates.map(timestampMillis));
return new Date(latest || timestampMillis(fallback) || Date.now()).toISOString();
}
function normalizeProjectLockFields(project: Partial<ProjectRecord>, fallback: string) {
const snapshotPath = typeof project.lockedPoseSnapshotPath === 'string' && project.lockedPoseSnapshotPath.trim()
? project.lockedPoseSnapshotPath.trim().slice(0, 240)
: null;
return {
locked: project.locked === true,
lockedAt: normalizeOptionalTimestamp(project.lockedAt),
unlockedAt: normalizeOptionalTimestamp(project.unlockedAt),
lastProcessedAt: latestProjectTimestamp(project, fallback),
lockedPoseSnapshotPath: snapshotPath,
};
}
function projectActivityTime(project: ProjectRecord) {
return timestampMillis(project.lastProcessedAt) || timestampMillis(project.createTime);
}
function sortProjectsByLastProcessed(projects: ProjectRecord[]) {
return [...projects].sort((a, b) => projectActivityTime(b) - projectActivityTime(a));
}
function touchProject(project: ProjectRecord, at = now()) {
project.lastProcessedAt = at;
}
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 getProjectDicomInfoCachePath(project: ProjectRecord) {
const dicomAssetDir = getProjectDicomDir(project);
const resolvedDir = path.resolve(dicomAssetDir);
const resolvedUploadDir = path.resolve(uploadDir);
if (!resolvedDir.startsWith(`${resolvedUploadDir}${path.sep}`)) {
return null;
}
return path.join(resolvedDir, '.revoxelseg-dicom-info.json');
}
function readCachedDicomInfo(project: ProjectRecord, files: string[]) {
const cachePath = getProjectDicomInfoCachePath(project);
if (!cachePath || !fs.existsSync(cachePath)) {
return null;
}
try {
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as { files?: string[]; info?: unknown };
if (!Array.isArray(cached.files) || cached.files.join('|') !== files.join('|') || !cached.info) {
return null;
}
return cached.info;
} catch {
return null;
}
}
function writeCachedDicomInfo(project: ProjectRecord, files: string[], info: unknown) {
const cachePath = getProjectDicomInfoCachePath(project);
if (!cachePath) {
return;
}
fs.writeFileSync(cachePath, JSON.stringify({ generatedAt: now(), files, info }, null, 2));
}
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];
const clampedValue = typeof nextValue === 'number' && Number.isFinite(nextValue)
? clampNumber(nextValue, min, max)
: fallback;
const precision = modelPoseValuePrecision[key];
return typeof precision === 'number' ? Number(clampedValue.toFixed(precision)) : clampedValue;
};
const readBoolean = (key: keyof ModelPoseValue, fallback: boolean) => (
typeof value?.[key] === 'boolean' ? Boolean(value?.[key]) : 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, 3),
flipX: readBoolean('flipX', defaultModelPose.flipX),
flipY: readBoolean('flipY', defaultModelPose.flipY),
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
};
}
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');
const createdAt = now();
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,
locked: false,
lockedAt: null,
unlockedAt: null,
lastProcessedAt: createdAt,
lockedPoseSnapshotPath: null,
moduleStyles: buildModuleStyles(stlFiles),
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
function buildEmptyProject(name: string): ProjectRecord {
const createdAt = now();
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,
locked: false,
lockedAt: null,
unlockedAt: null,
lastProcessedAt: createdAt,
lockedPoseSnapshotPath: null,
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);
const modelPoses = normalizeModelPoses(project.modelPoses);
const segmentationResults = normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount);
const lockFields = normalizeProjectLockFields({ ...project, segmentationResults }, typeof project.createTime === 'string' ? project.createTime : now());
return {
...project,
...lockFields,
dicomPath,
modelPath,
dicomCount,
stlFiles,
hasModel: stlFiles.length > 0,
modelCount: stlFiles.length,
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles,
modelPoses,
segmentationResults,
};
})
: [];
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);
const defaultSegmentationResults = normalizeSegmentationResults(
savedDefaultProject?.segmentationResults,
defaultStlFiles,
defaultModuleStyles,
defaultDicomCount,
);
const defaultLockFields = normalizeProjectLockFields(
{ ...savedDefaultProject, segmentationResults: defaultSegmentationResults },
savedDefaultProject?.createTime ?? defaultProject.createTime,
);
return {
...state,
projects: [
{
...defaultProject,
...defaultLockFields,
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: defaultSegmentationResults,
},
...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 * (pose.flipX ? -1 : 1);
let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
pz += 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,
};
}
const defaultAutoMatchWeights: AutoMatchWeights = {
boneReward: 1,
missPenalty: 0.45,
movementPenalty: 0.08,
scalePenalty: 0.12,
};
const defaultAutoMatchAdjustable: AutoMatchParameterSelection = {
translateX: true,
translateY: true,
translateZ: true,
scale: true,
};
const autoMatchParameterKeys: AutoMatchParameterKey[] = ['translateX', 'translateY', 'translateZ', 'scale'];
const autoMatchBoneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
interface AutoMatchContext {
project: ProjectRecord;
volume: DicomHuVolume;
metrics: ExportSceneMetrics;
samples: Point3DRecord[];
basePose: ModelPoseValue;
weights: AutoMatchWeights;
}
function normalizeAutoMatchAdjustable(input: unknown): AutoMatchParameterSelection {
const source = input && typeof input === 'object' && !Array.isArray(input)
? input as Partial<Record<AutoMatchParameterKey, unknown>>
: {};
return autoMatchParameterKeys.reduce<AutoMatchParameterSelection>((accumulator, key) => {
accumulator[key] = typeof source[key] === 'boolean' ? source[key] === true : defaultAutoMatchAdjustable[key];
return accumulator;
}, { ...defaultAutoMatchAdjustable });
}
function normalizeAutoMatchWeights(input: unknown): AutoMatchWeights {
const source = input && typeof input === 'object' && !Array.isArray(input)
? input as Partial<Record<keyof AutoMatchWeights, unknown>>
: {};
const readWeight = (key: keyof AutoMatchWeights, min: number, max: number) => {
const value = source[key];
return typeof value === 'number' && Number.isFinite(value)
? Number(clampNumber(value, min, max).toFixed(3))
: defaultAutoMatchWeights[key];
};
return {
boneReward: readWeight('boneReward', 0.2, 2),
missPenalty: readWeight('missPenalty', 0, 1.5),
movementPenalty: readWeight('movementPenalty', 0, 0.4),
scalePenalty: readWeight('scalePenalty', 0, 0.6),
};
}
function normalizeAutoMatchIterations(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? clampNumber(Math.round(value), 2, 12)
: 6;
}
function normalizeAutoMatchCandidatesPerRound(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? clampNumber(Math.round(value), 12, 80)
: 36;
}
function resolveAutoMatchBoneFiles(project: ProjectRecord, input: unknown) {
const requested = Array.isArray(input)
? input.filter((item): item is string => typeof item === 'string' && project.stlFiles.includes(item))
: [];
if (requested.length) {
return [...new Set(requested)];
}
const matched = project.stlFiles.filter((fileName) => autoMatchBoneNamePattern.test(fileName));
return matched.length ? matched : project.stlFiles;
}
function chooseAutoMatchSampleSlices(input: unknown, depth: number) {
const maxSlice = Math.max(depth - 1, 0);
const requested = Array.isArray(input)
? input
.map((item) => typeof item === 'number' && Number.isFinite(item) ? Math.round(item) : NaN)
.filter((item) => Number.isFinite(item))
.map((item) => clampNumber(item, 0, maxSlice))
: [];
if (requested.length) {
return [...new Set(requested)].sort((a, b) => a - b);
}
const fractions = [0.12, 0.22, 0.32, 0.42, 0.5, 0.58, 0.68, 0.78, 0.88];
return [...new Set(fractions.map((fraction) => clampNumber(Math.round(maxSlice * fraction), 0, maxSlice)))]
.sort((a, b) => a - b);
}
function collectAutoMatchPreviews(project: ProjectRecord) {
return (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
const filePath = getProjectModelFilePath(project, fileName);
if (fs.existsSync(filePath)) {
accumulator[fileName] = createStlPreview(filePath, fileName, 5000);
}
return accumulator;
}, {});
}
function collectAutoMatchSamples(project: ProjectRecord, boneFiles: string[]) {
const sampleBudgetPerFile = Math.max(1200, Math.floor(70000 / Math.max(boneFiles.length, 1)));
const samples: Point3DRecord[] = [];
boneFiles.forEach((fileName) => {
const filePath = getProjectModelFilePath(project, fileName);
if (!fs.existsSync(filePath)) {
return;
}
const preview = createStlPreview(filePath, fileName, sampleBudgetPerFile);
const vertices = preview.vertices;
for (let offset = 0; offset + 8 < vertices.length; offset += 9) {
samples.push({
x: (vertices[offset] + vertices[offset + 3] + vertices[offset + 6]) / 3,
y: (vertices[offset + 1] + vertices[offset + 4] + vertices[offset + 7]) / 3,
z: (vertices[offset + 2] + vertices[offset + 5] + vertices[offset + 8]) / 3,
});
}
});
return samples;
}
function readAutoMatchVolumeHu(volume: DicomHuVolume, slice: number, x: number, y: number) {
if (slice < 0 || slice >= volume.depth || x < 0 || x >= volume.width || y < 0 || y >= volume.height) {
return -Infinity;
}
return volume.data.readInt16LE((slice * volume.width * volume.height + y * volume.width + x) * 2);
}
function sampleAutoMatchBoneWindow(volume: DicomHuVolume, slice: number, x: number, y: number) {
const pixelX = Math.round(x);
const pixelY = Math.round(y);
if (pixelX < 0 || pixelX >= volume.width || pixelY < 0 || pixelY >= volume.height) {
return { value: -0.6, outside: true };
}
const centerHu = readAutoMatchVolumeHu(volume, slice, pixelX, pixelY);
let bestHu = centerHu;
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
bestHu = Math.max(bestHu, readAutoMatchVolumeHu(volume, slice, pixelX + dx, pixelY + dy));
}
}
if (centerHu >= 180) {
return { value: 1, outside: false };
}
if (bestHu >= 220) {
return { value: 0.65, outside: false };
}
if (bestHu >= 140) {
return { value: 0.3, outside: false };
}
return { value: -1, outside: false };
}
function mapAutoMatchPointToVolume(point: Point3DRecord, metrics: ExportSceneMetrics, volume: DicomHuVolume) {
const slice = volume.depth <= 1
? 0
: Math.round(((point.z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1));
const x = ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width;
const y = volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height;
return { slice, x, y };
}
function evaluateAutoMatchPose(
context: AutoMatchContext,
pose: ModelPoseValue,
iteration: number,
mode: string,
changed: AutoMatchParameterKey[],
): AutoMatchCandidateRecord {
let hitReward = 0;
let missPenalty = 0;
let contributors = 0;
context.samples.forEach((sample) => {
const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose);
const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume);
contributors += 1;
if (mapped.slice < 0 || mapped.slice >= context.volume.depth) {
missPenalty += 0.8;
return;
}
const bone = sampleAutoMatchBoneWindow(context.volume, mapped.slice, mapped.x, mapped.y);
if (bone.value > 0) {
hitReward += bone.value;
} else {
missPenalty += bone.outside ? 0.65 : 1;
}
});
const safeContributors = Math.max(contributors, 1);
const movement = Math.sqrt(
(pose.translateX - context.basePose.translateX) ** 2
+ (pose.translateY - context.basePose.translateY) ** 2
+ (pose.translateZ - context.basePose.translateZ) ** 2,
);
const movementPenalty = (movement / 0.05) * context.weights.movementPenalty;
const scalePenalty = (Math.abs(pose.scale - context.basePose.scale) / 0.02) * context.weights.scalePenalty;
const normalizedHitReward = (hitReward / safeContributors) * context.weights.boneReward;
const normalizedMissPenalty = (missPenalty / safeContributors) * context.weights.missPenalty;
const score = normalizedHitReward - normalizedMissPenalty - movementPenalty - scalePenalty;
return {
iteration,
mode,
pose: normalizeModelPoseValue(pose),
score: Number(score.toFixed(6)),
boneReward: Number(normalizedHitReward.toFixed(6)),
missPenalty: Number(normalizedMissPenalty.toFixed(6)),
movementPenalty: Number(movementPenalty.toFixed(6)),
scalePenalty: Number(scalePenalty.toFixed(6)),
contributors,
changed,
};
}
function autoMatchStepForParameter(key: AutoMatchParameterKey, iteration: number) {
const translationSteps = [0.04, 0.025, 0.014, 0.008, 0.004, 0.002, 0.001, 0.001];
const scaleSteps = [0.035, 0.02, 0.011, 0.006, 0.003, 0.0015, 0.001, 0.001];
const steps = key === 'scale' ? scaleSteps : translationSteps;
return steps[Math.min(iteration, steps.length - 1)];
}
function poseWithAutoMatchDelta(pose: ModelPoseValue, key: AutoMatchParameterKey, delta: number) {
return normalizeModelPoseValue({
...pose,
[key]: pose[key] + delta,
});
}
function generateAutoMatchCandidates(
pose: ModelPoseValue,
adjustable: AutoMatchParameterSelection,
iteration: number,
limit: number,
) {
const candidates = new Map<string, { pose: ModelPoseValue; mode: string; changed: AutoMatchParameterKey[] }>();
const addCandidate = (candidatePose: ModelPoseValue, mode: string, changed: AutoMatchParameterKey[]) => {
const key = JSON.stringify(candidatePose);
if (!candidates.has(key)) {
candidates.set(key, { pose: candidatePose, mode, changed });
}
};
const enabledKeys = autoMatchParameterKeys.filter((key) => adjustable[key]);
addCandidate(pose, '保持当前', []);
enabledKeys.forEach((key) => {
const step = autoMatchStepForParameter(key, iteration);
addCandidate(poseWithAutoMatchDelta(pose, key, step), `${key} +`, [key]);
addCandidate(poseWithAutoMatchDelta(pose, key, -step), `${key} -`, [key]);
});
for (let left = 0; left < enabledKeys.length; left += 1) {
for (let right = left + 1; right < enabledKeys.length; right += 1) {
const leftKey = enabledKeys[left];
const rightKey = enabledKeys[right];
const leftStep = autoMatchStepForParameter(leftKey, iteration) * 0.65;
const rightStep = autoMatchStepForParameter(rightKey, iteration) * 0.65;
[-1, 1].forEach((leftSign) => {
[-1, 1].forEach((rightSign) => {
const candidatePose = poseWithAutoMatchDelta(
poseWithAutoMatchDelta(pose, leftKey, leftStep * leftSign),
rightKey,
rightStep * rightSign,
);
addCandidate(candidatePose, `${leftKey}/${rightKey} 联合`, [leftKey, rightKey]);
});
});
}
}
return [...candidates.values()].slice(0, limit);
}
function createAutoMatchContext(project: ProjectRecord, body: Record<string, unknown>) {
const files = getProjectDicomFiles(project);
if (!files.length) {
throw new Error('当前项目没有可匹配的 DICOM 文件');
}
if (!project.stlFiles.length) {
throw new Error('当前项目没有可匹配的 STL 文件');
}
const volume = readDicomHuVolume(project, files);
const previews = collectAutoMatchPreviews(project);
const metrics = getExportMetrics(project, volume, previews);
if (!metrics) {
throw new Error('无法读取 STL 全局边界');
}
const boneFiles = resolveAutoMatchBoneFiles(project, body.boneFiles);
const samples = collectAutoMatchSamples(project, boneFiles);
if (!samples.length) {
throw new Error('未能从骨骼 STL 中采样到可匹配点');
}
return {
context: {
project,
volume,
metrics,
samples,
basePose: normalizeModelPoseValue(body.pose as Partial<ModelPoseValue> | undefined),
weights: normalizeAutoMatchWeights(body.weights),
} satisfies AutoMatchContext,
boneFiles,
sampleSlices: chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth),
adjustable: normalizeAutoMatchAdjustable(body.adjustable),
iterations: normalizeAutoMatchIterations(body.iterations),
candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound),
};
}
function runProjectAutoMatch(project: ProjectRecord, body: Record<string, unknown>) {
const {
context,
boneFiles,
sampleSlices,
adjustable,
iterations,
candidatesPerRound,
} = createAutoMatchContext(project, body);
let best = evaluateAutoMatchPose(context, context.basePose, -1, '初始位姿', []);
const trace: AutoMatchCandidateRecord[] = [];
let evaluated = 1;
for (let iteration = 0; iteration < iterations; iteration += 1) {
const candidates = generateAutoMatchCandidates(best.pose, adjustable, iteration, candidatesPerRound);
const evaluatedCandidates = candidates
.map((candidate) => evaluateAutoMatchPose(context, candidate.pose, iteration, candidate.mode, candidate.changed))
.sort((left, right) => right.score - left.score);
evaluated += evaluatedCandidates.length;
trace.push(...evaluatedCandidates.slice(0, Math.min(6, evaluatedCandidates.length)));
if (evaluatedCandidates[0] && evaluatedCandidates[0].score > best.score + 0.000001) {
best = evaluatedCandidates[0];
}
}
return {
projectId: project.id,
basePose: context.basePose,
bestPose: best.pose,
bestScore: best.score,
iterations,
evaluated,
boneFiles,
sampleSlices,
weights: context.weights,
trace,
};
}
function applyAutoMatchedPose(project: ProjectRecord, pose: ModelPoseValue) {
const normalizedPose = normalizeModelPoseValue(pose);
const poses = normalizeModelPoses(project.modelPoses).filter((record) => record.id !== 'auto-match');
project.modelPoses = normalizeModelPoses([
...poses,
{
id: 'auto-match',
name: '自动微调匹配',
pose: normalizedPose,
},
]);
const latestResult = project.segmentationResults[project.segmentationResults.length - 1];
if (latestResult) {
latestResult.pose = normalizedPose;
}
}
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 groupExportSegmentsByConnectivity(segments: PlaneSegmentRecord[], tolerance = 1.35) {
if (segments.length <= 1) {
return segments.length ? [segments] : [];
}
const parents = segments.map((_, index) => index);
const find = (index: number): number => {
if (parents[index] !== index) {
parents[index] = find(parents[index]);
}
return parents[index];
};
const union = (left: number, right: number) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) {
parents[rightRoot] = leftRoot;
}
};
const buckets = new Map<string, Array<{ x: number; y: number; index: number }>>();
const cellSize = Math.max(tolerance, 0.1);
const toleranceSquared = tolerance * tolerance;
const cellKey = (x: number, y: number) => `${x},${y}`;
segments.forEach((segment, index) => {
[segment.a, segment.b].forEach((point) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
return;
}
const cellX = Math.floor(point.x / cellSize);
const cellY = Math.floor(point.y / cellSize);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
candidates?.forEach((candidate) => {
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
if (distanceSquared <= toleranceSquared) {
union(index, candidate.index);
}
});
}
}
const key = cellKey(cellX, cellY);
const bucket = buckets.get(key) ?? [];
bucket.push({ x: point.x, y: point.y, index });
buckets.set(key, bucket);
});
});
const groups = new Map<number, PlaneSegmentRecord[]>();
segments.forEach((segment, index) => {
const root = find(index);
const group = groups.get(root) ?? [];
group.push(segment);
groups.set(root, group);
});
return [...groups.values()].sort((left, right) => right.length - left.length);
}
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 closeExportMaskGaps(mask: Uint8Array, width: number, height: number, maxGap = 2) {
const toFill = new Set<number>();
const hasPixel = (x: number, y: number) => mask[y * width + x] > 0;
const mark = (x: number, y: number) => {
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) {
toFill.add(y * width + x);
}
};
for (let y = 0; y < height; y += 1) {
let lastFilled = -1;
for (let x = 0; x < width; x += 1) {
if (!hasPixel(x, y)) {
continue;
}
const gap = x - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) {
mark(fillX, y);
}
}
lastFilled = x;
}
}
for (let x = 0; x < width; x += 1) {
let lastFilled = -1;
for (let y = 0; y < height; y += 1) {
if (!hasPixel(x, y)) {
continue;
}
const gap = y - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) {
mark(x, fillY);
}
}
lastFilled = y;
}
}
toFill.forEach((index) => {
mask[index] = 1;
});
return toFill.size;
}
function exportSolidStrokeRadius(width: number, height: number) {
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
}
function paintExportMaskPixel(mask: Uint8Array, width: number, height: number, x: number, y: number) {
if (x < 0 || x >= width || y < 0 || y >= height) {
return 0;
}
const index = y * width + x;
if (mask[index]) {
return 0;
}
mask[index] = 1;
return 1;
}
function fillExportSegmentCapsules(
mask: Uint8Array,
width: number,
height: number,
segments: PlaneSegmentRecord[],
radius: number,
) {
let paintedPixels = 0;
const radiusSquared = radius * radius;
segments.forEach(({ a, b }) => {
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
return;
}
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const minX = clampNumber(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
const maxX = clampNumber(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
const minY = clampNumber(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
const maxY = clampNumber(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const t = lengthSquared <= 1e-6
? 0
: clampNumber(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
const closestX = a.x + dx * t;
const closestY = a.y + dy * t;
const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
if (distanceSquared <= radiusSquared) {
paintedPixels += paintExportMaskPixel(mask, width, height, x, y);
}
}
}
});
return paintedPixels;
}
function fillExportRows(
data: Buffer,
width: number,
height: number,
slice: number,
rows: number[][],
label: number,
solidSegments: PlaneSegmentRecord[] = [],
) {
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 (solidSegments.length) {
filledPixels += fillExportSegmentCapsules(mask, width, height, solidSegments, exportSolidStrokeRadius(width, height));
}
if (filledPixels === 0) {
return 0;
}
filledPixels += closeExportMaskGaps(mask, width, height, 3);
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 fillExportFallbackClosedRegion(
data: Buffer,
width: number,
height: number,
slice: number,
segments: PlaneSegmentRecord[],
label: number,
) {
const points = segments.flatMap((segment) => [segment.a, segment.b])
.filter((point) => (
Number.isFinite(point.x)
&& Number.isFinite(point.y)
&& point.x >= -width
&& point.x <= width * 2
&& point.y >= -height
&& point.y <= height * 2
));
if (points.length < 3) {
return 0;
}
const uniquePoints: Point2DRecord[] = [];
points.forEach((point) => {
if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-6)) {
uniquePoints.push(point);
}
});
if (uniquePoints.length < 3) {
return 0;
}
const sorted = [...uniquePoints].sort((left, right) => (
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
));
const cross = (origin: Point2DRecord, a: Point2DRecord, b: Point2DRecord) => (
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
);
const lower: Point2DRecord[] = [];
sorted.forEach((point) => {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
lower.pop();
}
lower.push(point);
});
const upper: Point2DRecord[] = [];
[...sorted].reverse().forEach((point) => {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
upper.pop();
}
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
const polygon = hull.length >= 3 ? hull : uniquePoints;
const rows = Array.from({ length: height }, () => [] as number[]);
polygon.forEach((point, index) => {
const nextPoint = polygon[(index + 1) % polygon.length];
addExportSegmentToRows(rows, width, height, { a: point, b: nextPoint });
});
return fillExportRows(data, width, height, slice, rows, label);
}
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 slicesByIndex = new Map<number, { segments: PlaneSegmentRecord[] }>();
const entryForSlice = (slice: number) => {
const existing = slicesByIndex.get(slice);
if (existing) {
return existing;
}
const entry = {
segments: [] as PlaneSegmentRecord[],
};
slicesByIndex.set(slice, entry);
return entry;
};
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;
}
const mappedSegment = {
a: mapPoint(segment.a),
b: mapPoint(segment.b),
};
const entry = entryForSlice(slice);
entry.segments.push(mappedSegment);
}
});
slicesByIndex.forEach(({ segments }, slice) => {
groupExportSegmentsByConnectivity(segments, exportSolidStrokeRadius(volume.width, volume.height) * 1.15).forEach((group) => {
const rows = Array.from({ length: volume.height }, () => [] as number[]);
group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment));
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label, group);
if (filledPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, 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 parseModuleStylesQuery(raw: unknown) {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
return parsed as Record<string, Partial<ModuleStyleRecord>>;
} 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,
requestedModuleStyles?: Record<string, Partial<ModuleStyleRecord>>,
): ProjectRecord {
const latestResult = latestSegmentationResult(project);
const mergedStyles = buildModuleStyles(project.stlFiles, {
...(latestResult?.moduleStyles ?? {}),
...(project.moduleStyles ?? {}),
...(requestedModuleStyles ?? {}),
});
return {
...project,
moduleStyles: mergedStyles,
};
}
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 trimTarText(value: Buffer) {
return value.toString('utf8').replace(/\0.*$/s, '').trim();
}
function looksLikeDicom(data: Buffer) {
return data.length > 132 && data.toString('ascii', 128, 132) === 'DICM';
}
function isImportableAsset(kind: 'dicom' | 'stl', name: string, data: Buffer) {
const lowerName = name.toLowerCase();
if (kind === 'stl') {
return lowerName.endsWith('.stl');
}
return lowerName.endsWith('.dcm') || lowerName.endsWith('.dicom') || looksLikeDicom(data);
}
function parseTarEntries(data: Buffer, sourceName: string): PreparedAssetFile[] {
const entries: PreparedAssetFile[] = [];
let offset = 0;
while (offset + 512 <= data.length) {
const header = data.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) {
break;
}
const name = trimTarText(header.subarray(0, 100));
const prefix = trimTarText(header.subarray(345, 500));
const typeFlag = header.toString('utf8', 156, 157);
const sizeValue = trimTarText(header.subarray(124, 136)).replace(/[^0-7]/g, '');
const size = Number.parseInt(sizeValue || '0', 8) || 0;
const dataStart = offset + 512;
const dataEnd = Math.min(dataStart + size, data.length);
const archiveName = prefix ? `${prefix}/${name}` : name;
if ((typeFlag === '0' || typeFlag === '') && archiveName && size > 0) {
entries.push({
name: archiveName,
data: Buffer.from(data.subarray(dataStart, dataEnd)),
});
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
if (!entries.length) {
throw new Error(`${sourceName} 中没有可读取的 TAR 文件条目`);
}
return entries;
}
function expandUploadedAsset(file: Express.Multer.File): PreparedAssetFile[] {
const lowerName = file.originalname.toLowerCase();
if (lowerName.endsWith('.zip')) {
const archive = new AdmZip(file.path);
return archive.getEntries()
.filter((entry) => !entry.isDirectory)
.map((entry) => ({ name: entry.entryName, data: entry.getData() }));
}
if (lowerName.endsWith('.tar.gz') || lowerName.endsWith('.tgz')) {
return parseTarEntries(zlib.gunzipSync(fs.readFileSync(file.path)), file.originalname);
}
if (lowerName.endsWith('.tar')) {
return parseTarEntries(fs.readFileSync(file.path), file.originalname);
}
if (lowerName.endsWith('.gz')) {
const outputName = file.originalname.replace(/\.gz$/i, '') || `${file.originalname}.raw`;
return [{ name: outputName, data: zlib.gunzipSync(fs.readFileSync(file.path)) }];
}
return [{ name: file.originalname, data: fs.readFileSync(file.path) }];
}
function safeImportedFileName(name: string, fallback: string, extension: string, usedNames: Set<string>) {
const initial = sanitizeUploadFileName(name, fallback, extension);
const parsed = path.parse(initial);
let candidate = initial;
let suffix = 2;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${parsed.name}-${suffix}${parsed.ext || extension}`;
suffix += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
}
function collectPreparedAssetFiles(kind: 'dicom' | 'stl', uploadedFiles: Express.Multer.File[], legacyFiles: UploadedAssetPayload[]) {
const expandedFiles: PreparedAssetFile[] = [];
uploadedFiles.forEach((file) => {
expandedFiles.push(...expandUploadedAsset(file));
});
legacyFiles.forEach((file) => {
expandedFiles.push({ name: file.name, data: decodeUploadedAssetData(file.data) });
});
return expandedFiles.filter((file) => file.data.length > 0 && isImportableAsset(kind, file.name, file.data));
}
function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
files.forEach((file) => {
if (file.path) {
fs.rmSync(file.path, { force: true });
}
});
}
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 createProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
const latestResult = latestSegmentationResult(project);
const activePose = latestResult?.pose
?? project.modelPoses.find((pose) => pose.id === 'default')?.pose
?? project.modelPoses[0]?.pose
?? defaultModelPose;
return {
schemaVersion: 1,
lockedAt,
project: {
id: project.id,
name: project.name,
status: project.status,
createTime: project.createTime,
dicomCount: project.dicomCount,
modelCount: project.modelCount,
dicomPath: project.dicomPath,
modelPath: project.modelPath,
stlFiles: project.stlFiles,
},
activePose,
modelPoses: project.modelPoses,
moduleStyles: project.moduleStyles,
latestSegmentationResult: latestResult ?? null,
note: 'This snapshot is written when a project is locked from the project list. It stores ReVoxelSeg pose and style data, not raw DICOM or STL files.',
};
}
function writeProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
ensureDir(lockedResultDir);
const filename = `${sanitizeFilenamePart(project.name, project.id)}-${timestampForFilename(new Date(lockedAt))}-pose-lock.json`;
const filePath = path.join(lockedResultDir, filename);
const exportProject = projectWithSegmentationResultStyles(project);
fs.writeFileSync(filePath, JSON.stringify(createProjectLockSnapshot(exportProject, lockedAt), null, 2), 'utf8');
return toRepoRelativePath(filePath);
}
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-parts/${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, maxPreviewTriangles));
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 writeTarText(buffer: Buffer, value: string, offset: number, length: number) {
const source = Buffer.from(value, 'utf8');
source.copy(buffer, offset, 0, Math.min(length, source.length));
}
function createPaxRecord(key: string, value: string) {
const payload = `${key}=${value}\n`;
let length = Buffer.byteLength(payload, 'utf8') + 3;
while (true) {
const record = `${length} ${payload}`;
const nextLength = Buffer.byteLength(record, 'utf8');
if (nextLength === length) {
return record;
}
length = nextLength;
}
}
function createTarEntryHeader(name: string, size: number, mtime: number, typeFlag: '0' | 'x' = '0') {
const header = Buffer.alloc(512);
writeTarText(header, name, 0, 100);
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(typeFlag, 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[] = [];
const pushEntry = (name: string, data: Buffer, mtime: number, typeFlag: '0' | 'x' = '0') => {
chunks.push(createTarEntryHeader(name, data.length, mtime, typeFlag));
chunks.push(data);
const remainder = data.length % 512;
if (remainder > 0) {
chunks.push(Buffer.alloc(512 - remainder));
}
};
entries.forEach((entry, index) => {
const data = entry.data;
const mtime = entry.mtime ?? Date.now() / 1000;
const needsPaxPath = Buffer.byteLength(entry.name, 'utf8') > 100 || /[^\x20-\x7e]/.test(entry.name);
let headerName = entry.name;
if (needsPaxPath) {
const paxData = Buffer.from(createPaxRecord('path', entry.name), 'utf8');
const paxName = `PaxHeaders/${String(index + 1).padStart(6, '0')}`;
headerName = `entries/${String(index + 1).padStart(6, '0')}`;
pushEntry(paxName, paxData, mtime, 'x');
}
pushEntry(headerName, data, mtime);
});
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);
ensureDir(uploadTempDir);
const assetUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, callback) => {
ensureDir(uploadTempDir);
callback(null, uploadTempDir);
},
filename: (_req, file, callback) => {
const safeName = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}-${path.basename(file.originalname).replace(/[\\/:*?"<>|]+/g, '_')}`;
callback(null, safeName);
},
}),
limits: {
files: 5000,
fileSize: 2 * 1024 * 1024 * 1024,
},
});
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(sortProjectsByLastProcessed(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;
touchProject(project);
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/lock', (req, res) => {
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const shouldLock = req.body?.locked === true;
const changedAt = now();
try {
if (shouldLock) {
project.locked = true;
project.lockedAt = changedAt;
project.unlockedAt = null;
project.lockedPoseSnapshotPath = writeProjectLockSnapshot(project, changedAt);
touchProject(project, changedAt);
} else {
project.locked = false;
project.unlockedAt = changedAt;
touchProject(project, changedAt);
}
writeState(state);
res.json(project);
} catch (error) {
res.status(500).json({ message: error instanceof Error ? error.message : '项目锁定状态更新失败' });
}
});
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>>),
});
touchProject(project);
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>[]);
touchProject(project);
writeState(state);
res.json(project);
});
app.post('/api/projects/:projectId/auto-match', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
try {
res.json(runProjectAutoMatch(project, req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {}));
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '自动微调匹配失败' });
}
});
app.patch('/api/projects/:projectId/model-pose', (req, res) => {
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
if (project.locked) {
res.status(423).json({ message: '项目已锁定,请先解锁后再写入自动匹配位姿' });
return;
}
try {
applyAutoMatchedPose(project, normalizeModelPoseValue(req.body?.pose as Partial<ModelPoseValue> | undefined));
touchProject(project);
writeState(state);
res.json(project);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '位姿保存失败' });
}
});
app.post('/api/projects/:projectId/import-assets', (req, res) => {
assetUpload.array('files', 5000)(req, res, (uploadError) => {
if (uploadError) {
res.status(413).json({ message: uploadError instanceof Error ? uploadError.message : '上传文件过大或格式无效' });
return;
}
const kind = req.body?.kind === 'stl' ? 'stl' : 'dicom';
const multerFiles = Array.isArray(req.files) ? req.files as Express.Multer.File[] : [];
let legacyUploadedFiles: UploadedAssetPayload[];
try {
legacyUploadedFiles = req.body?.files ? parseUploadedAssets(req.body.files) : [];
} catch (error) {
cleanupUploadedTempFiles(multerFiles);
res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' });
return;
}
if (!multerFiles.length && !legacyUploadedFiles.length) {
res.status(400).json({ message: '请选择需要导入的文件' });
return;
}
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
cleanupUploadedTempFiles(multerFiles);
res.status(404).json({ message: '项目不存在' });
return;
}
const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL');
try {
const preparedFiles = collectPreparedAssetFiles(kind, multerFiles, legacyUploadedFiles);
if (!preparedFiles.length) {
throw new Error(kind === 'dicom'
? '未找到可导入的 DICOM 文件,请选择 .dcm/.dicom 文件或包含 DICOM 的 ZIP/TAR 压缩包'
: '未找到可导入的 STL 文件,请选择 .stl 文件或包含 STL 的 ZIP/TAR 压缩包');
}
fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir);
const usedNames = new Set<string>();
preparedFiles.forEach((file, index) => {
const fileName = safeImportedFileName(
file.name,
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
kind === 'dicom' ? '.dcm' : '.stl',
usedNames,
);
fs.writeFileSync(path.join(targetDir, fileName), file.data);
});
if (kind === 'dicom') {
const dicomFiles = listFiles(targetDir, '.dcm');
project.dicomPath = toRepoRelativePath(targetDir);
project.dicomCount = dicomFiles.length;
project.segmentationResults = [];
const dicomInfo = createDicomInfo(project, dicomFiles);
writeCachedDicomInfo(project, dicomFiles, dicomInfo);
} 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';
touchProject(project);
clearProjectRuntimeCaches(project.id);
writeState(state);
res.json(project);
} catch (error) {
console.error('[import-assets] failed', {
projectId: req.params.projectId,
kind,
fileCount: multerFiles.length + legacyUploadedFiles.length,
message: error instanceof Error ? error.message : error,
});
res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' });
} finally {
cleanupUploadedTempFiles(multerFiles);
}
});
});
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,
);
touchProject(project, record.createdAt);
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 {
const cachedInfo = readCachedDicomInfo(project, files);
if (cachedInfo) {
res.json(cachedInfo);
return;
}
const info = createDicomInfo(project, files);
writeCachedDicomInfo(project, files, info);
res.json(info);
} 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, parseModuleStylesQuery(req.query.moduleStyles));
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;
touchProject(project);
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, parseModuleStylesQuery(req.query.moduleStyles));
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;
touchProject(project);
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);
});