2026-05-20-03-19-25 完善分割结果保存与STL导出

This commit is contained in:
2026-05-20 03:33:21 +08:00
parent b9c0f17313
commit 25f34d1eef
10 changed files with 667 additions and 78 deletions

View File

@@ -9,7 +9,7 @@ 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';
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
type SegmentationExportScope = 'all' | 'visible';
interface ModuleStyleRecord {
@@ -35,6 +35,15 @@ interface ModelPoseRecord {
pose: ModelPoseValue;
}
interface SegmentationResultRecord {
id: string;
name: string;
createdAt: string;
segmentationScope: SegmentationExportScope;
pose: ModelPoseValue;
moduleStyles: Record<string, ModuleStyleRecord>;
}
interface UserRecord {
id: number;
name: string;
@@ -60,6 +69,7 @@ interface ProjectRecord {
isDefault?: boolean;
moduleStyles: Record<string, ModuleStyleRecord>;
modelPoses: ModelPoseRecord[];
segmentationResults: SegmentationResultRecord[];
}
interface SessionRecord {
@@ -107,6 +117,15 @@ const defaultModelPose: ModelPoseValue = {
translateZ: 0,
scale: 1,
};
const headCtBestPose: ModelPoseValue = {
rotateX: -180,
rotateY: 0,
rotateZ: 1,
translateX: -0.03,
translateY: -0.155,
translateZ: 0.005,
scale: 1,
};
interface DicomAttributes {
patientName: string;
@@ -217,6 +236,7 @@ function buildModuleStyles(
function defaultModelPoses(): ModelPoseRecord[] {
return [
{ id: 'default', name: '默认', pose: { ...defaultModelPose } },
{ id: 'best', name: '最佳位姿', pose: { ...headCtBestPose } },
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
];
@@ -270,6 +290,36 @@ function normalizeModelPoses(existing?: Partial<ModelPoseRecord>[]) {
return [...normalizedDefaults, ...custom];
}
function normalizeSegmentationResults(
existing: Partial<SegmentationResultRecord>[] | undefined,
stlFiles: string[],
currentModuleStyles: Record<string, ModuleStyleRecord>,
) {
if (!Array.isArray(existing)) {
return [];
}
return existing
.map((record, index): SegmentationResultRecord => {
const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles)
? record.moduleStyles
: currentModuleStyles;
return {
id: typeof record?.id === 'string' && record.id.trim()
? record.id.trim().slice(0, 80)
: `segmentation-${index}`,
name: typeof record?.name === 'string' && record.name.trim()
? record.name.trim().slice(0, 80)
: `分割结果 ${index + 1}`,
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),
};
})
.slice(-20);
}
function buildDefaultProject(): ProjectRecord {
const stlFiles = listFiles(modelDir, '.stl');
@@ -289,6 +339,7 @@ function buildDefaultProject(): ProjectRecord {
isDefault: true,
moduleStyles: buildModuleStyles(stlFiles),
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
@@ -308,6 +359,7 @@ function buildEmptyProject(name: string): ProjectRecord {
exportedMaskCount: 0,
moduleStyles: {},
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
@@ -329,15 +381,21 @@ function normalizeState(state: AppState): AppState {
const customProjects = Array.isArray(state.projects)
? state.projects
.filter((project) => project.id !== defaultProject.id)
.map((project) => ({
...project,
stlFiles: Array.isArray(project.stlFiles) ? project.stlFiles : [],
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles),
modelPoses: normalizeModelPoses(project.modelPoses),
}))
.map((project) => {
const stlFiles = Array.isArray(project.stlFiles) ? project.stlFiles : [];
const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
return {
...project,
stlFiles,
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles,
modelPoses: normalizeModelPoses(project.modelPoses),
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles),
};
})
: [];
const defaultModuleStyles = buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles);
return {
...state,
@@ -346,8 +404,13 @@ function normalizeState(state: AppState): AppState {
...defaultProject,
name: savedDefaultProject?.name ?? defaultProject.name,
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
moduleStyles: defaultModuleStyles,
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
segmentationResults: normalizeSegmentationResults(
savedDefaultProject?.segmentationResults,
defaultProject.stlFiles,
defaultModuleStyles,
),
},
...customProjects,
],
@@ -1084,7 +1147,7 @@ function parseSegmentationScope(raw: unknown): SegmentationExportScope {
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 === 'dicom' || value === 'segmentation' || value === 'pose' || value === 'stl'
));
return [...new Set(targets)];
}
@@ -1177,6 +1240,22 @@ function createProjectExportBundle({
});
}
if (targets.includes('stl')) {
(project.stlFiles ?? []).forEach((fileName) => {
const filePath = path.join(modelDir, 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('未选择可导出的内容');
}
@@ -1984,6 +2063,39 @@ async function startServer() {
res.json(project);
});
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)}`,
name: rawName || `分割结果 ${project.segmentationResults.length + 1}`,
createdAt: now(),
segmentationScope: parseSegmentationScope(req.body?.segmentationScope),
pose: normalizeModelPoseValue(req.body?.pose as Partial<ModelPoseValue> | undefined),
moduleStyles: buildModuleStyles(project.stlFiles, rawStyles),
};
project.segmentationResults = normalizeSegmentationResults(
[...(project.segmentationResults ?? []), record],
project.stlFiles,
project.moduleStyles,
);
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) {