2026-05-20-03-19-25 完善分割结果保存与STL导出
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user