3941 lines
132 KiB
TypeScript
3941 lines
132 KiB
TypeScript
import express, { type Request, type Response } 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));
|
||
}
|
||
|
||
function sendJsonPayload(req: Request, res: Response, payload: unknown) {
|
||
const json = JSON.stringify(payload);
|
||
const acceptsGzip = String(req.headers['accept-encoding'] ?? '').includes('gzip');
|
||
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
res.setHeader('Vary', 'Accept-Encoding');
|
||
if (acceptsGzip && Buffer.byteLength(json) > 64 * 1024) {
|
||
const compressed = zlib.gzipSync(Buffer.from(json));
|
||
res.setHeader('Content-Encoding', 'gzip');
|
||
res.setHeader('Content-Length', String(compressed.length));
|
||
res.send(compressed);
|
||
return;
|
||
}
|
||
|
||
res.send(json);
|
||
}
|
||
|
||
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.1,
|
||
movementPenalty: 0,
|
||
scalePenalty: 0,
|
||
};
|
||
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[];
|
||
sampleSlices: number[];
|
||
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, 50)
|
||
: 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 nearestAutoMatchSampleSlice(slice: number, sampleSlices: number[], tolerance = 1) {
|
||
let nearest: number | null = null;
|
||
let nearestDistance = Infinity;
|
||
sampleSlices.forEach((candidate) => {
|
||
const distance = Math.abs(candidate - slice);
|
||
if (distance < nearestDistance) {
|
||
nearest = candidate;
|
||
nearestDistance = distance;
|
||
}
|
||
});
|
||
return nearestDistance <= tolerance ? nearest : null;
|
||
}
|
||
|
||
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);
|
||
const sampleSlice = nearestAutoMatchSampleSlice(mapped.slice, context.sampleSlices);
|
||
if (sampleSlice === null) {
|
||
return;
|
||
}
|
||
contributors += 1;
|
||
|
||
if (sampleSlice < 0 || sampleSlice >= context.volume.depth) {
|
||
missPenalty += 0.8;
|
||
return;
|
||
}
|
||
|
||
const bone = sampleAutoMatchBoneWindow(context.volume, sampleSlice, 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 sampleSlices = chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth);
|
||
const samples = collectAutoMatchSamples(project, boneFiles);
|
||
if (!samples.length) {
|
||
throw new Error('未能从骨骼 STL 中采样到可匹配点');
|
||
}
|
||
|
||
return {
|
||
context: {
|
||
project,
|
||
volume,
|
||
metrics,
|
||
samples,
|
||
sampleSlices,
|
||
basePose: normalizeModelPoseValue(body.pose as Partial<ModelPoseValue> | undefined),
|
||
weights: normalizeAutoMatchWeights(body.weights),
|
||
} satisfies AutoMatchContext,
|
||
boneFiles,
|
||
sampleSlices,
|
||
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,
|
||
bestMode: best.mode,
|
||
bestChanged: best.changed,
|
||
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 {
|
||
sendJsonPayload(req, res, 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 },
|
||
allowedHosts: ['revoxel.huijutec.cn'],
|
||
},
|
||
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);
|
||
});
|