diff --git a/Agent.md b/Agent.md new file mode 100644 index 0000000..8e2b86d --- /dev/null +++ b/Agent.md @@ -0,0 +1,18 @@ +# ReVoxelSeg DICOM 系统使用方式 + +## 入口与登录 + +启动项目后访问系统首页,使用管理员账号进入工作台。左侧导航包含总体概况、项目库、逆向工作区和系统管理。日常工作建议先进入项目库确认当前项目、DICOM 数量、STL 构件数量和分割结果状态,再进入逆向工作区进行位姿调整、切片校验和结果保存。 + +## 主要功能 + +ReVoxelSeg DICOM 是一个围绕 DICOM 影像、STL 三维重建模型与医学分割 Label Map 生成的逆向体素化工作系统。项目库用于集中管理病例项目,可查看 DICOM 原始切片、切换横断面/矢状面/冠状面浏览方式,调整窗宽窗位预设,并预览 STL 构件层级。构件层级支持颜色、透明度、显示隐藏状态和分割 ID 设置,这些设置会同步影响逆向工作区的三维融合视图、二维映射视图和最终导出的分割影像。逆向工作区提供三栏协同校验:左侧为影像与模型融合视角,用于观察 DICOM 体数据与 STL 模型在同一物理坐标系下的空间关系;中间为可视化工具栏,可调整模型显示精度、DICOM 透明度、切片范围、构件层级以及模型位姿;右侧为逆向分割映射视图,在 DICOM 原始切片上叠加由 STL 网格平面求交和实体填充生成的 Overlay Label Map。系统默认使用 `head-ct-demo-pose-data.json` 中记录的最佳位姿,用户仍可通过旋转、平移、缩放输入框进行精细调整,并可导入或保存位姿。右侧 Slice Navigator 支持逐层检查当前 Z 轴切片中模型边界和分割实体区域,只显示当前切片实际出现的构件提示,便于快速判断每一层的分割质量。完成校验后,可点击“保存至项目库”将当前位姿、构件样式和分割范围保存为项目库中的分割结果。导出功能支持将 DICOM 原始影像、分割 NIfTI、位姿 JSON、labels JSON 和 STL 原始模型打包为 `.tar.gz`,方便在 ITK-SNAP 等工具中复核,也方便把同一批数据交付给算法工程师或临床医生继续分析。 + +## 推荐流程 + +1. 在项目库选择 `头部 CT 模型逆向体素化演示`。 +2. 进入逆向工作区,确认默认加载的是 `最佳位姿`。 +3. 在可视化工具栏调整构件颜色、透明度和显隐状态。 +4. 使用右侧 Slice Navigator 逐层检查 Overlay Label Map。 +5. 点击 `保存至项目库`,回到项目库的 `分割结果` 区域复核。 +6. 点击 `导出全部 NII.GZ`,按需要选择 DICOM、分割影像、位姿数据和 STL 原始模型。 diff --git a/WebSite/server.ts b/WebSite/server.ts index 7898a50..73f67be 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -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; +} + interface UserRecord { id: number; name: string; @@ -60,6 +69,7 @@ interface ProjectRecord { isDefault?: boolean; moduleStyles: Record; 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[]) { return [...normalizedDefaults, ...custom]; } +function normalizeSegmentationResults( + existing: Partial[] | undefined, + stlFiles: string[], + currentModuleStyles: Record, +) { + 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>), + } + : 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 | 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) { diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 92208aa..aeada2c 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -21,8 +21,8 @@ import { Upload } from 'lucide-react'; import * as THREE from 'three'; -import { DicomInfo, DicomPreview, ModuleStyle, Project } from '../types'; -import { api, downloadDicomArchive, downloadMask } from '../lib/api'; +import { DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; +import { api, downloadDicomArchive, downloadMask, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api'; type Plane = 'axial' | 'sagittal' | 'coronal'; type DisplayMode = DicomPreview['mode']; @@ -52,6 +52,16 @@ interface ModelPreviewPayload { type ModelPoseKey = keyof ModelPose; const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [ + { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, + { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' }, + { id: 'pose', label: '位姿数据', description: 'JSON 侧车' }, + { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' }, +]; +const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ + { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, + { id: 'all', label: '所有类别', description: '包含隐藏构件' }, +]; const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [ { id: 'standard', label: '标准', limit: 16000 }, { id: 'fine', label: '精细', limit: 36000 }, @@ -655,6 +665,15 @@ export default function ProjectLibrary({ const [editingProjectId, setEditingProjectId] = useState(''); const [editingName, setEditingName] = useState(''); const [actionMessage, setActionMessage] = useState(''); + const [showMaskExportMenu, setShowMaskExportMenu] = useState(false); + const [maskExportSelection, setMaskExportSelection] = useState>({ + dicom: true, + segmentation: true, + pose: true, + stl: false, + }); + const [maskSegmentationScope, setMaskSegmentationScope] = useState('visible'); + const [maskExporting, setMaskExporting] = useState(false); const sliceRepeatRef = useRef(null); const dicomRequestRef = useRef(0); const preloadedProjectIdsRef = useRef(new Set()); @@ -697,6 +716,8 @@ export default function ProjectLibrary({ useEffect(() => { if (selectedProject) { preloadProjectAssets(selectedProject); + const latestResult = selectedProject.segmentationResults?.[selectedProject.segmentationResults.length - 1]; + setMaskSegmentationScope(latestResult?.segmentationScope ?? 'visible'); } }, [selectedProject?.id]); @@ -723,6 +744,8 @@ export default function ProjectLibrary({ const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0; const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0]; + const savedSegmentationResults = selectedProject?.segmentationResults ?? []; + const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; const makeDefaultModuleStyle = (index: number, fallback?: Partial): ModuleStyle => ({ visible: fallback?.visible ?? true, @@ -746,6 +769,34 @@ export default function ProjectLibrary({ }); }; + const handleMaskBundleExport = async () => { + if (!selectedProject) { + return; + } + + const selectedTargets = exportOptions + .filter((option) => maskExportSelection[option.id]) + .map((option) => option.id); + if (!selectedTargets.length) { + setActionMessage('请至少选择一个导出内容'); + return; + } + + setMaskExporting(true); + setActionMessage(''); + try { + await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', { + pose: latestSegmentationResult?.pose ?? modelPose, + segmentationScope: maskSegmentationScope, + }); + window.setTimeout(() => setMaskExporting(false), 900); + setShowMaskExportMenu(false); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : '导出失败'); + setMaskExporting(false); + } + }; + useEffect(() => { setViewMode(initialViewMode); }, [initialViewMode]); @@ -1449,44 +1500,130 @@ export default function ProjectLibrary({ )} {viewMode === 'mask' && ( -
-
-
- {['#3b82f6', '#22c55e', '#f59e0b'].map((color, index) => ( -
- ))} -
-
- SEGMENTATION MASK PREVIEW · NII/NII.GZ +
+
+
+
+

分割结果

+

+ 来自逆向工作区保存的 Label Map 复核记录 +

+
+ + {savedSegmentationResults.length} 条 +
+ + {savedSegmentationResults.length ? ( +
+ {savedSegmentationResults.map((result, index) => ( +
+
+

{result.name}

+ + #{index + 1} + +
+
+ 范围:{result.segmentationScope === 'all' ? '所有类别' : '可见类别'} + {new Date(result.createdAt).toLocaleString('zh-CN', { hour12: false })} + RX {result.pose.rotateX.toFixed(0)}° + TZ {result.pose.translateZ.toFixed(3)} +
+
+ ))} +
+ ) : ( +
+ 暂无保存的分割结果,请在逆向工作区点击“保存至项目库”。 +
+ )}
+
-
-

分割结果

-

- 当前项目可导出 NIfTI 格式分割 mask。NII.GZ 为默认全量导出格式,适合后续医学影像工具链读取。 +

+

导出分割包

+

+ 默认使用最新保存的分割结果位姿;可选择导出 DICOM、Label Map、位姿数据与原始 STL。

+
+ + {showMaskExportMenu && ( +
+
+

导出内容

+ +
+
+ {exportOptions.map((option) => ( + + ))} +
+ {maskExportSelection.segmentation && ( +
+
+

分割类别范围

+ 附带 labels.json +
+
+ {segmentationScopeOptions.map((option) => ( + + ))} +
+
+ )} + +
+ )} +
-
diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index eeaa149..259aeaa 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -72,9 +72,19 @@ const defaultModelPose: ModelPose = { translateZ: 0, scale: 1, }; +const headCtBestPose: ModelPose = { + rotateX: -180, + rotateY: 0, + rotateZ: 1, + translateX: -0.03, + translateY: -0.155, + translateZ: 0.005, + scale: 1, +}; const defaultSavedPoses: SavedModelPose[] = [ { 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 } }, ]; @@ -82,6 +92,7 @@ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' }, { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' }, { id: 'pose', label: '位姿数据', description: 'JSON 侧车' }, + { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' }, ]; const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, @@ -1084,6 +1095,15 @@ interface OverlayStats { activeModules: number; filledPixels: number; segmentCount: number; + modules: Array<{ + fileName: string; + name: string; + color: string; + opacity: number; + partId: number; + segmentCount: number; + filledPixels: number; + }>; } function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null { @@ -1593,13 +1613,13 @@ function drawVoxelOverlayLayer( canvas.height = fovCanvas.height; const context = canvas.getContext('2d'); if (!context) { - return { activeModules: 0, filledPixels: 0, segmentCount: 0 }; + return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] }; } context.clearRect(0, 0, fovCanvas.width, fovCanvas.height); const metrics = getModelSceneMetrics(files, previews, preview, totalSlices); if (!metrics) { - return { activeModules: 0, filledPixels: 0, segmentCount: 0 }; + return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] }; } const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0)); @@ -1613,6 +1633,7 @@ function drawVoxelOverlayLayer( let activeModules = 0; let filledPixels = 0; let segmentCount = 0; + const modules: OverlayStats['modules'] = []; files.forEach((fileName, index) => { const payload = previews[fileName]; @@ -1662,14 +1683,23 @@ function drawVoxelOverlayLayer( } const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity); - if (segments.length > 0) { + if (segments.length > 0 || modulePixels > 0) { activeModules += 1; + modules.push({ + fileName, + name: fileName.replace(/\.stl$/i, ''), + color: style.color, + opacity: style.opacity, + partId: style.partId, + segmentCount: segments.length, + filledPixels: modulePixels, + }); } filledPixels += modulePixels; segmentCount += segments.length; }); - return { activeModules, filledPixels, segmentCount }; + return { activeModules, filledPixels, segmentCount, modules }; } function VoxelizationMappingView({ @@ -1695,7 +1725,7 @@ function VoxelizationMappingView({ const [modelPreviews, setModelPreviews] = useState>({}); const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片'); const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射'); - const [overlayStats, setOverlayStats] = useState({ activeModules: 0, filledPixels: 0, segmentCount: 0 }); + const [overlayStats, setOverlayStats] = useState({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] }); const maxSlice = Math.max(totalSlices - 1, 0); const safeSlice = clamp(slice, 0, maxSlice); const stlFiles = project?.stlFiles ?? []; @@ -1842,22 +1872,24 @@ function VoxelizationMappingView({ {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
-
- {stlFiles.map((fileName, index) => { - const style = moduleStyles[fileName] ?? { - visible: true, - color: moduleColors[index % moduleColors.length], - opacity: 0.72, - partId: index + 1, - }; - return ( -
- - {fileName.replace(/\.stl$/i, '')} - ID {style.partId} -
- ); - })} +
+ {overlayStats.modules.length ? ( +
+ {overlayStats.modules.map((item) => ( +
+ + {item.name} + ID {item.partId} + {item.segmentCount} 边 + {item.filledPixels} px +
+ ))} +
+ ) : ( +
+ 当前切片暂无可见构件 +
+ )}
@@ -1928,11 +1960,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { dicom: true, segmentation: true, pose: true, + stl: false, }); const [segmentationExportScope, setSegmentationExportScope] = useState('visible'); const [project, setProject] = useState(null); const [fusionVolume, setFusionVolume] = useState(null); const [fusionError, setFusionError] = useState(''); + const [saveStatus, setSaveStatus] = useState(''); const [exporting, setExporting] = useState(false); const fusionVolumeCacheRef = useRef(new Map()); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); @@ -1973,6 +2007,27 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { } }; + const handleSaveSegmentationResult = async () => { + if (!project) { + return; + } + + setFusionError(''); + setSaveStatus(''); + try { + const updated = await api.saveProjectSegmentationResult(project.id, { + name: `分割结果 ${new Date().toLocaleString('zh-CN', { hour12: false })}`, + pose: modelPose, + segmentationScope: segmentationExportScope, + moduleStyles, + }); + setProject(updated); + setSaveStatus('已保存至项目库的分割结果区域'); + } catch (error) { + setFusionError(error instanceof Error ? error.message : '保存至项目库失败'); + } + }; + const makeDefaultModuleStyle = (index: number, fallback?: Partial): ModuleStyle => ({ visible: fallback?.visible ?? true, color: fallback?.color ?? moduleColors[index % moduleColors.length], @@ -2021,14 +2076,19 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { setSliceStart(0); setSliceEnd(maxIndex); setMappingSlice(maxIndex); - setModelPose(defaultModelPose); + const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses; + const preferredPose = nextPoses.find((pose) => pose.id === 'best') + ?? nextPoses.find((pose) => pose.name.includes('最佳')) + ?? nextPoses.find((pose) => pose.name === '位姿2') + ?? nextPoses[0]; + setModelPose(preferredPose?.pose ?? headCtBestPose); const nextStyles: Record = {}; (item.stlFiles ?? []).forEach((fileName, index) => { nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]); }); setModuleStyles(nextStyles); - setSavedPoses(item.modelPoses?.length ? item.modelPoses : defaultSavedPoses); - setSelectedPoseId('default'); + setSavedPoses(nextPoses); + setSelectedPoseId(preferredPose?.id ?? 'best'); }).catch(() => { setProject(null); setFusionVolume(null); @@ -2315,7 +2375,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

导出内容

-
-
+
+

影像与模型融合视角 @@ -2462,7 +2522,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

-
+

@@ -2746,13 +2806,21 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

-
+

逆向分割映射视图

-
+
+
+ {saveStatus && ( +
+ {saveStatus} +
+ )} (path: string, options: RequestInit = {}): Promise { const response = await fetch(path, { @@ -63,6 +63,19 @@ export const api = { method: 'PATCH', body: JSON.stringify({ modelPoses }), }), + saveProjectSegmentationResult: ( + projectId: string, + payload: { + name?: string; + pose: ModelPose; + segmentationScope: SegmentationExportScope; + moduleStyles: Record; + }, + ) => + request(`/api/projects/${projectId}/segmentation-results`, { + method: 'POST', + body: JSON.stringify(payload), + }), getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') => request(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`), getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') => @@ -99,7 +112,7 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) { const params = new URLSearchParams({ target, format }); - if (target !== 'dicom') { + if (target === 'segmentation' || target === 'pose') { appendPose(params, options.pose); } if (target === 'segmentation') { diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index fc56164..dd7a80e 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -22,6 +22,7 @@ export interface Project { isDefault?: boolean; moduleStyles?: Record; modelPoses?: SavedModelPose[]; + segmentationResults?: SegmentationResult[]; } export interface ModuleStyle { @@ -47,6 +48,17 @@ export interface SavedModelPose { pose: ModelPose; } +export type SegmentationExportScope = 'all' | 'visible'; + +export interface SegmentationResult { + id: string; + name: string; + createdAt: string; + segmentationScope: SegmentationExportScope; + pose: ModelPose; + moduleStyles: Record; +} + export interface MaskMapping { className: string; color: string; diff --git a/工程分析/实现方案-2026-05-20-03-19-25.md b/工程分析/实现方案-2026-05-20-03-19-25.md new file mode 100644 index 0000000..25a8795 --- /dev/null +++ b/工程分析/实现方案-2026-05-20-03-19-25.md @@ -0,0 +1,62 @@ +# 实现方案:导出与项目库分割结果闭环 + +实现方案文档路径:`工程分析/实现方案-2026-05-20-03-19-25.md` + +## 修改目标 + +补齐原始 STL 导出、分割结果保存到项目库、项目库复用导出包功能、工作区布局与右侧构件提示优化,并新增系统使用说明 `Agent.md`。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/types.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `Agent.md` +- `工程分析/需求分析-2026-05-20-03-19-25.md` +- `工程分析/实现方案-2026-05-20-03-19-25.md` +- `工程分析/测试方案-2026-05-20-03-19-25.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 后端导出目标新增 `stl`,在 `/export-bundle` 中将 `Head_CT_ReConstruct` 下的原始 STL 文件写入同一个 tar.gz。 +2. 项目状态新增 `segmentationResults`,保存当前分割结果的名称、时间、位姿、分割范围与构件样式快照。 +3. 新增 `POST /api/projects/:projectId/segmentation-results`,供逆向工作区 `保存至项目库` 调用。 +4. 项目库 `分割结果` 视图读取保存记录,并提供与逆向工作区一致的导出内容选择、分割范围选择和压缩包下载。 +5. 将默认/推荐位姿加入前后端默认位姿列表,逆向工作区加载项目时优先选择 `最佳位姿`。 +6. 工作区三栏布局从 `6/3/3` 调整为更偏向工具栏与映射视图的比例,压缩左侧融合视角。 +7. `OverlayStats` 增加当前 slice 的构件明细,图例仅显示当前切片中实际有边或像素的构件,并用紧凑表格呈现。 +8. 编写 `Agent.md`,包含系统使用方式和 500~1300 字主要功能说明。 + +## 执行步骤 + +1. 增加类型定义和后端状态归一化。 +2. 扩展导出包生成逻辑与 API 参数解析。 +3. 增加项目库保存接口与前端 API。 +4. 修改逆向工作区按钮、布局、右侧图例。 +5. 修改项目库分割结果区域和导出菜单。 +6. 编写 `Agent.md`。 +7. 运行 lint、build、接口导出验证、保存接口验证、部署验证。 +8. 追加经验记录、提交、推送、重新部署。 + +## 兼容性与回滚方案 + +- 原有导出目标 `dicom/segmentation/pose` 保持兼容。 +- `stl` 为新增可选目标,默认可不选以避免包过大。 +- 旧项目没有 `segmentationResults` 时归一化为空数组。 +- 回滚到上一提交会移除项目库保存结果和 STL 导出选项,但不影响原始医学数据。 + +## 预计文件变更 + +- 后端状态、导出和保存接口。 +- 前端 API、类型、逆向工作区与项目库 UI。 +- 新增 `Agent.md`。 +- 工程分析与经验记录。 + +## 提交与部署策略 + +- 只暂存本次相关文件。 +- commit message 包含 `2026-05-20-03-19-25` 和简要说明。 +- 推送 Gitea 后使用 `tmux` 会话 `revoxelseg-dicom` 重启服务并验证。 diff --git a/工程分析/测试方案-2026-05-20-03-19-25.md b/工程分析/测试方案-2026-05-20-03-19-25.md new file mode 100644 index 0000000..dc41c84 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-03-19-25.md @@ -0,0 +1,79 @@ +# 测试方案:导出 STL、保存分割结果与项目库复核 + +测试方案文档路径:`工程分析/测试方案-2026-05-20-03-19-25.md` + +## 静态检查 + +- 运行 `npm run lint`,确认 TypeScript 类型检查通过。 +- 运行 `git diff --check`,确认无空白错误。 + +结果:已通过。 + +## 构建检查 + +- 在 `WebSite/` 执行 `npm run build`,确认生产构建通过。 + +结果:已通过。Vite 仍有单 bundle 超过 500 kB 的既有提示,不影响本次功能。 + +## 关键业务场景验证 + +- 调用 `POST /api/projects/head-ct-demo/segmentation-results`,确认返回项目包含保存记录。 +- 调用 `/api/projects/head-ct-demo/export-bundle?targets=stl`,确认压缩包内包含原始 STL 文件。 +- 调用 `/api/projects/head-ct-demo/export-bundle?targets=segmentation,pose,stl`,确认分割、位姿与 STL 可共包导出。 +- 验证项目库 `分割结果` 右侧导出菜单参数与逆向工作区一致。 + +结果:已通过。 + +- 临时服务 `127.0.0.1:4100` 下保存接口返回 `segmentationResults`,并可看到默认 `最佳位姿`。 +- `targets=stl` 返回 HTTP 200,压缩包约 `116M`,目录包含 9 个原始 STL:会厌、气管上段、气管狭窄段、气管下段、气管整体、声门、头部、头颅、肿瘤。 +- `targets=segmentation,pose,stl` 返回 HTTP 200,约 `121433464` bytes,用时约 `7.58s`,包内包含分割 NIfTI、labels JSON、pose JSON 和 STL 原始模型。 + +## 医学影像数据相关边界验证 + +- 分割导出仍保持 DICOM 同维度 NIfTI。 +- 保存的分割结果导出时使用保存时位姿。 +- `visible/all` 分割范围继续影响 Label Map 与 labels JSON。 + +结果:已覆盖导出包结构验证;分割 NIfTI 生成逻辑未改变,继续复用上一轮完整 STL 网格实体导出链路。 + +## UI 验证 + +- 逆向工作区顶部出现 `保存至项目库`。 +- 三栏布局中左侧融合视角变窄,中部工具栏和右侧映射视图变宽。 +- 右侧图例只显示当前 slice 中有边或填充像素的构件;无构件时显示空状态。 +- 项目库分割结果区域显示保存记录和导出入口。 + +## 文档验证 + +- `Agent.md` 存在。 +- `Agent.md` 包含当前系统使用方式。 +- `主要功能` 说明在 500~1300 字范围内。 + +结果:已通过。`Agent.md` 总字数约 `1105` 字符,主要功能段落在 500~1300 字范围内。 + +## 部署验证 + +- 重新部署后验证: + - `http://127.0.0.1:4000/api/health` + - `http://127.0.0.1:4000/` +- 部署后验证导出包接口。 + +结果:已通过。 + +- `tmux` 会话 `revoxelseg-dicom` 已重启,服务输出 `ReVoxelSeg DICOM server ready at http://0.0.0.0:4000/`。 +- `/api/health` 返回 `{"ok":true,"service":"revoxelseg-dicom"}`。 +- 首页 HTTP 200。 +- 部署后 `targets=stl` 导出返回 HTTP 200,约 `121372189` bytes,压缩包内包含 9 个 STL 原始模型。 + +## Git/Gitea 备份验证 + +- 提交信息包含 `2026-05-20-03-19-25` 和本次修改摘要。 +- 推送到 Gitea 成功。 + +结果:本地 Git commit 已完成;Gitea 推送重试 2 次均失败,错误为 `No route to host`,说明当前环境到 `192.168.31.5:5002` 网络不可达。待网络恢复后执行 `git push origin main` 即可补推。 + +## 风险与回归关注点 + +- STL 原始模型打包体积较大,接口验证可先用 `targets=stl` 查看目录,不反复下载全量组合。 +- 临时保存接口测试会修改 `WebSite/data/state.json`,该运行态文件不纳入提交。 +- 旧的未暂存历史删除状态不应混入提交。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index c32e237..5a3607c 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1135,3 +1135,21 @@ C. 解决问题方案 D. 后续如何避免问题 预览数据、抽样数据和医学导出数据必须明确分层。凡是 NIfTI、Mask、Label Map、体素化这类可用于医学工具复核的导出功能,都不得复用用于前端性能优化的抽样网格;验证时必须检查大面数构件的 label 体素数量、slice 覆盖范围和侧视/冠状重建是否连续。 + +## 2026-05-20-03-19-25 分割结果保存必须携带位姿与导出上下文 + +A. 具体问题 + +用户要求在逆向工作区将当前“逆向分割映射视图”保存至项目库,并在项目库分割结果区域继续导出。如果只保存一个名称或时间,项目库无法复现保存当时的位姿、类别范围和构件样式。 + +B. 产生问题原因 + +分割结果不是一个静态按钮状态,而是由模型位姿、构件显隐、颜色、透明度、partId 和分割范围共同决定;这些上下文如果仍散落在组件 state 或只存在当前页面,重新进入项目库后导出的结果可能与保存时不一致。 + +C. 解决问题方案 + +项目状态新增 `segmentationResults`,保存当前结果名称、时间、`segmentationScope`、模型位姿和构件样式快照;逆向工作区通过后端接口写入项目,项目库分割结果区域读取该记录,并用最新保存结果的位姿作为导出默认输入。导出包新增 `stl` 目标,将原始 STL 文件放入同一个 tar.gz 中。 + +D. 后续如何避免问题 + +凡是“保存至项目库”“保存结果”“复核后导出”类功能,都必须明确保存的是结果本体还是生成结果所需的上下文。医学导出相关记录至少要包含位姿、分割范围、标签样式和生成时间;新增导出目标时也要同步检查逆向工作区和项目库两个入口是否一致。 diff --git a/工程分析/需求分析-2026-05-20-03-19-25.md b/工程分析/需求分析-2026-05-20-03-19-25.md new file mode 100644 index 0000000..44e5c77 --- /dev/null +++ b/工程分析/需求分析-2026-05-20-03-19-25.md @@ -0,0 +1,65 @@ +# 需求分析:导出 STL、保存分割结果与工作区布局优化 + +开始时间:2026-05-20-03-19-25 + +## 原始需求摘要 + +用户提出以下修改: + +1. `导出全部 NII.GZ` 增加 `导出 STL 原始模型` 选项。 +2. 逆向工作区 `逆向分割映射视图` 上方增加 `保存至项目库` 按钮,将当前分割结果推送到项目库的 `分割结果` 区域;项目库分割结果右侧需要具备与 `导出全部 NII.GZ` 一致的导出功能。 +3. `影像与模型融合视角` 仍占地过大,需要让 `可视化工具栏` 与 `逆向分割映射视图` 更宽。 +4. `逆向分割映射视图` 下方仅显示当前切片中实际出现的构件,并改为更紧密的表格形式,一行可以显示多个构件提示。 +5. `head-ct-demo-pose-data.json` 是最合适的位姿,需要作为当前系统推荐/默认位姿使用。 +6. 使用 `Agent.md` 撰写当前系统使用方式,并输出 500~1300 字的主要功能说明。 + +## 业务目标 + +- 让导出包成为 DICOM、分割、位姿、原始 STL 的统一归档入口。 +- 将逆向工作区中的分割结果沉淀到项目库,便于后续复核和导出。 +- 提高三栏工作区中操作区和二维校验区的可用宽度。 +- 右侧切片图例只表达当前 slice 实际存在的 Overlay 构件,减少无关干扰。 +- 默认加载最优位姿,降低用户每次进入项目后的手动调整成本。 +- 提供清晰的系统使用说明文档。 + +## 输入与输出 + +- 输入: + - 当前项目 `head-ct-demo`。 + - 当前模型位姿、构件样式、分割导出范围。 + - `head-ct-demo-pose-data.json` 中的最佳位姿。 +- 输出: + - 新增 STL 原始模型导出包内容。 + - 项目级保存的分割结果记录。 + - 项目库分割结果区域与导出入口。 + - 优化后的工作区布局和切片构件表格。 + - `Agent.md` 使用说明。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/types.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `Agent.md` +- 工程分析文档与经验记录。 + +## 关键约束 + +- 保存至项目库必须写入后端项目状态,不能只存在组件内存。 +- 导出 STL 原始模型不能把大型 STL 文件提交到 Git,只在运行时打包下载。 +- 继续避免浏览器 blob 下载路径,保持后端附件直链。 +- 文档备份提交只包含本次相关代码和文档,不纳入旧的未确认删除。 + +## 风险点 + +- 原始 STL 总体积较大,选择导出 STL 会明显增加压缩包体积和下载耗时。 +- 保存分割结果若不携带位姿,会导致项目库导出的分割与保存时不一致。 +- 图例改为当前 slice 构件后,需要依赖 Overlay 计算结果,不能只看全局显隐状态。 + +## 默认假设 + +- `保存至项目库` 保存当前位姿、导出范围和当前构件样式快照。 +- 项目库分割结果导出默认使用最新保存结果的位姿与范围。 +- `Agent.md` 放在项目根目录,作为系统使用说明文档。