diff --git a/WebSite/server.ts b/WebSite/server.ts index be39317..0b0e017 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -9,6 +9,8 @@ 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 SegmentationExportScope = 'all' | 'visible'; interface ModuleStyleRecord { visible: boolean; @@ -772,7 +774,20 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb }); } -function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, pose: ModelPoseValue) { +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') { const data = Buffer.alloc(volume.width * volume.height * volume.depth); const previews = (project.stlFiles ?? []).reduce>((accumulator, fileName) => { const filePath = path.join(modelDir, fileName); @@ -803,14 +818,9 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p (project.stlFiles ?? []).forEach((fileName, index) => { const payload = previews[fileName]; - const style = project.moduleStyles[fileName] ?? { - visible: true, - color: defaultModuleColors[index % defaultModuleColors.length], - opacity: 0.72, - partId: index + 1, - }; + const style = getModuleStyle(project, fileName, index); - if (!payload || style.visible === false) { + if (!payload || !isModuleIncludedForExport(style, scope)) { return; } @@ -872,6 +882,55 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p 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; @@ -884,13 +943,37 @@ function parseModelPoseQuery(raw: unknown) { } } -function createNiftiExport(project: ProjectRecord, files: string[], target: 'dicom' | 'segmentation', compressed: boolean, pose?: ModelPoseValue) { +function parseSegmentationScope(raw: unknown): SegmentationExportScope { + return raw === 'all' ? 'all' : 'visible'; +} + +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' + )); + return [...new Set(targets)]; +} + +function createNiftiExport( + project: ProjectRecord, + files: string[], + target: 'dicom' | 'segmentation', + compressed: boolean, + pose?: ModelPoseValue, + segmentationScope: SegmentationExportScope = 'visible', +) { const volume = readDicomHuVolume(files); if (target === 'dicom') { return createNiftiBuffer(volume, volume.data, 'dicom', compressed); } - return createNiftiBuffer(volume, createSegmentationData(project, volume, pose ?? defaultModelPose), 'segmentation', compressed); + return createNiftiBuffer( + volume, + createSegmentationData(project, volume, pose ?? defaultModelPose, segmentationScope), + 'segmentation', + compressed, + ); } function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) { @@ -909,6 +992,64 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) { }, null, 2), 'utf8'); } +function createProjectExportBundle({ + project, + files, + targets, + compressed, + activePose, + segmentationScope, +}: { + project: ProjectRecord; + files: string[]; + targets: ProjectExportTarget[]; + compressed: boolean; + activePose?: ModelPoseValue; + segmentationScope: SegmentationExportScope; +}) { + const entries: Array<{ name: string; data: Buffer; mtime?: number }> = []; + const needsVolume = targets.includes('dicom') || targets.includes('segmentation'); + const volume = needsVolume ? readDicomHuVolume(files) : null; + const format = compressed ? 'nii.gz' : 'nii'; + const exportRoot = `${project.id}-nifti-export`; + + 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) { + 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 (!entries.length) { + throw new Error('未选择可导出的内容'); + } + + return createTarGz(entries); +} + function findProject(state: AppState, projectId: string) { return state.projects.find((candidate) => candidate.id === projectId); } @@ -1443,14 +1584,12 @@ function createTarEntryHeader(name: string, size: number, mtime: number) { return header; } -function createDicomTarGz(files: string[]) { +function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) { const chunks: Buffer[] = []; - files.forEach((fileName) => { - const filePath = path.join(dicomDir, fileName); - const stat = fs.statSync(filePath); - const data = fs.readFileSync(filePath); - chunks.push(createTarEntryHeader(`Head_CT_DICOM/${fileName}`, data.length, stat.mtimeMs / 1000)); + entries.forEach((entry) => { + const data = entry.data; + chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000)); chunks.push(data); const remainder = data.length % 512; if (remainder > 0) { @@ -1462,6 +1601,18 @@ function createDicomTarGz(files: string[]) { return zlib.gzipSync(Buffer.concat(chunks)); } +function createDicomTarGz(files: string[]) { + return createTarGz(files.map((fileName) => { + const filePath = path.join(dicomDir, fileName); + const stat = fs.statSync(filePath); + return { + name: `Head_CT_DICOM/${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) { @@ -1889,6 +2040,7 @@ async function startServer() { const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation'); const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation'; const activePose = parseModelPoseQuery(req.query.pose); + const segmentationScope = parseSegmentationScope(req.query.segmentationScope); try { if (target === 'pose') { @@ -1904,7 +2056,7 @@ async function startServer() { const files = getProjectDicomFiles(project); const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; const compressed = format === 'nii.gz'; - const payload = createNiftiExport(project, files, target, compressed, activePose); + const payload = createNiftiExport(project, 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); @@ -1919,6 +2071,49 @@ async function startServer() { } }; + 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 activePose = parseModelPoseQuery(req.query.pose); + const segmentationScope = parseSegmentationScope(req.query.segmentationScope); + const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; + const compressed = format === 'nii.gz'; + + try { + const files = getProjectDicomFiles(project); + const payload = createProjectExportBundle({ + project, + files, + targets, + compressed, + activePose, + segmentationScope, + }); + const filename = `${project.id}-nifti-export.tar.gz`; + fs.writeFileSync(path.join(exportDir, filename), payload); + project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0; + writeState(state); + + res.setHeader('Content-Type', 'application/gzip'); + 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-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')); diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 4f10410..eeaa149 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -13,7 +13,7 @@ import { } from 'lucide-react'; import * as THREE from 'three'; import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; -import { api, downloadMask, downloadSelectedProjectExports, ProjectExportTarget } from '../lib/api'; +import { api, downloadMask, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api'; interface ModelPreviewPayload { fileName: string; @@ -83,6 +83,10 @@ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' }, { id: 'pose', label: '位姿数据', description: 'JSON 侧车' }, ]; +const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [ + { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' }, + { id: 'all', label: '所有类别', description: '包含隐藏构件' }, +]; const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const fusionBaseExtent = 4.6; const axisInsetLength = 17; @@ -1925,6 +1929,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { segmentation: true, pose: true, }); + const [segmentationExportScope, setSegmentationExportScope] = useState('visible'); const [project, setProject] = useState(null); const [fusionVolume, setFusionVolume] = useState(null); const [fusionError, setFusionError] = useState(''); @@ -1936,7 +1941,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const handleExport = async (format: 'nii' | 'nii.gz') => { setExporting(true); try { - await downloadMask(projectId, format, modelPose); + await downloadMask(projectId, format, modelPose, segmentationExportScope); } catch (error) { setFusionError(error instanceof Error ? error.message : '导出失败'); } finally { @@ -1956,8 +1961,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { setExporting(true); setFusionError(''); try { - await downloadSelectedProjectExports(projectId, selectedItems, 'nii.gz', { pose: modelPose }); - window.setTimeout(() => setExporting(false), selectedItems.length * 220 + 200); + await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', { + pose: modelPose, + segmentationScope: segmentationExportScope, + }); + window.setTimeout(() => setExporting(false), 900); setShowExportMenu(false); } catch (error) { setFusionError(error instanceof Error ? error.message : '导出失败'); @@ -2329,12 +2337,38 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { ))} + {exportSelection.segmentation && ( +
+
+

分割类别范围

+ 附带 labels.json +
+
+ {segmentationScopeOptions.map((option) => ( + + ))} +
+
+ )} )} diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 88983af..ab5f2b5 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -1,6 +1,7 @@ import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SessionState, UserRecord } from '../types'; export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose'; +export type SegmentationExportScope = 'all' | 'visible'; async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(path, { @@ -89,26 +90,32 @@ function appendPose(params: URLSearchParams, pose?: ModelPose) { } } -export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose) { +export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose, segmentationScope: SegmentationExportScope = 'visible') { const params = new URLSearchParams({ format }); appendPose(params, pose); + params.set('segmentationScope', segmentationScope); triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`); } -export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) { +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') { appendPose(params, options.pose); } + if (target === 'segmentation') { + params.set('segmentationScope', options.segmentationScope ?? 'visible'); + } triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`); } -export async function downloadSelectedProjectExports(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose } = {}) { - targets.forEach((target, index) => { - window.setTimeout(() => { - void downloadProjectExport(projectId, target, format, options); - }, index * 180); +export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) { + const params = new URLSearchParams({ + targets: targets.join(','), + format, + segmentationScope: options.segmentationScope ?? 'visible', }); + appendPose(params, options.pose); + triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`); } export async function downloadDicomArchive(projectId: string) { diff --git a/工程分析/实现方案-2026-05-20-02-32-47.md b/工程分析/实现方案-2026-05-20-02-32-47.md new file mode 100644 index 0000000..daa09cd --- /dev/null +++ b/工程分析/实现方案-2026-05-20-02-32-47.md @@ -0,0 +1,68 @@ +# 实现方案-2026-05-20-02-32-47 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-20-02-32-47.md` + +## 修改目标 + +将顶部“导出全部 NII.GZ”改为一次性下载 `.tar.gz` 导出包;分割影像导出时自动附带 label/category 映射 JSON,并支持“所有类别/可见类别”范围选择。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/需求分析-2026-05-20-02-32-47.md` +- `工程分析/实现方案-2026-05-20-02-32-47.md` +- `工程分析/测试方案-2026-05-20-02-32-47.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 后端扩展分割生成参数: + - 增加 `SegmentationExportScope = all | visible`。 + - `createSegmentationData` 按 scope 决定是否跳过隐藏构件。 +2. 后端新增类别映射 JSON: + - 根据 `project.stlFiles` 和 `project.moduleStyles` 生成 label/name/fileName/color/opacity/visible。 + - scope 为 visible 时只包含可见构件。 +3. 后端新增导出包接口: + - `GET /api/projects/:projectId/export-bundle` + - 参数:`targets=dicom,segmentation,pose`、`format=nii.gz`、`pose=...`、`segmentationScope=visible|all` + - 使用现有 tar header 工具封装为 `.tar.gz`。 +4. 前端 API: + - 新增 `downloadProjectExportBundle`。 + - 保留单个分割 NII/NII.GZ 下载兼容。 +5. 前端菜单: + - “导出所选”改为下载单个压缩包。 + - 增加分割类别范围的 segmented control。 + - 仅选中分割影像时显示类别范围选项。 + +## 执行步骤 + +- 新增后端 tar entry 通用函数和 bundle 生成函数。 +- 修改 NIfTI 分割生成函数支持 scope。 +- 新增 label map JSON 生成逻辑。 +- 调整前端 API 和导出菜单。 +- 执行 `npm run lint`、`npm run build`。 +- 用临时/正式服务验证 bundle 接口返回 `.tar.gz`,并检查包内文件名。 +- 追加经验记录、commit、push、部署。 + +## 兼容性与回滚方案 + +- `/export-nifti` 与 `/export-mask` 保持可用。 +- 单独分割导出默认沿用 visible scope。 +- 若 bundle 导出异常,可回滚到多文件直链下载。 + +## 预计文件变更 + +- `server.ts`:新增 bundle、分割 scope、类别映射 JSON。 +- `api.ts`:新增 bundle 下载 API。 +- `ReverseWorkspace.tsx`:导出菜单增加 scope 选择并调用 bundle。 +- 工程分析文档:新增本次文档,更新经验记录。 + +## 提交与部署策略 + +- Commit message:`2026-05-20-02-32-47 支持NII导出包与分割类别范围` +- 显式暂存本次相关文件,避免提交历史删除状态。 +- 推送到 Gitea `origin/main`,并使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。 diff --git a/工程分析/测试方案-2026-05-20-02-32-47.md b/工程分析/测试方案-2026-05-20-02-32-47.md new file mode 100644 index 0000000..b1004b7 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-02-32-47.md @@ -0,0 +1,62 @@ +# 测试方案-2026-05-20-02-32-47 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-20-02-32-47.md` + +## 静态检查 + +- 在 `WebSite/` 下执行 `npm run lint`。 + +## 构建检查 + +- 在 `WebSite/` 下执行 `npm run build`。 + +## 关键业务场景验证 + +- 顶部“导出全部 NII.GZ”选择多个内容后只触发一个压缩包下载。 +- 选中分割影像时,菜单显示“导出可见类别/导出所有类别”选项。 +- bundle 中包含所选 DICOM NIfTI、分割 NIfTI、位姿 JSON。 +- 若包含分割影像,bundle 中同时包含 `segmentation-labels.json`。 +- 类别 JSON 中 label/partId/name/fileName/color/opacity/visible 与项目构件层级一致。 + +## 医学影像数据相关边界验证 + +- DICOM NIfTI 仍为真实 DICOM 体数据同维同距。 +- 分割 NIfTI 仍使用当前模型位姿。 +- visible scope 下隐藏构件不进入分割图和类别 JSON。 +- all scope 下隐藏构件仍进入分割图和类别 JSON。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证: + - `curl http://127.0.0.1:4000/api/health` + - `curl -I http://127.0.0.1:4000/` + +## Git/Gitea 备份验证 + +- 显式暂存本次相关代码和文档。 +- 创建包含时间戳和描述的 commit。 +- 推送到 Gitea `origin/main`。 + +## 实测结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;仅保留 Vite chunk size 提醒。 +- 临时服务 `127.0.0.1:4100` 验证 `targets=segmentation,pose&segmentationScope=visible`:HTTP 200,返回 `.tar.gz`。 +- visible bundle 包内包含: + - `head-ct-demo-segmentation-label.nii.gz` + - `head-ct-demo-segmentation-labels.json` + - `head-ct-demo-pose-data.json` +- `segmentation-labels.json` 验证包含 `segmentationScope=visible`、`label/partId/name/categoryName/className/fileName/color/opacity/visible` 字段。 +- 临时服务验证 `targets=dicom,segmentation,pose&segmentationScope=all`:HTTP 200,返回约 75.90 MB `.tar.gz`。 +- all bundle 包内包含 DICOM NIfTI、分割 NIfTI、分割 labels JSON、位姿 JSON。 +- all labels JSON 验证包含隐藏构件,隐藏项 `visible=false` 仍保留。 +- bundle 内分割 NIfTI 头验证:`512x512x300`,datatype `2`,bitpix `8`,最大标签 `9`。 + +## 风险与回归关注点 + +- 避免重新引入 `URL.createObjectURL(blob)` 下载。 +- 避免提交历史工程文档删除状态。 +- 大体数据打包接口要捕获异常并返回明确错误。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index b135c5b..ab8fcf9 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1099,3 +1099,21 @@ C. 解决问题方案 D. 后续如何避免问题 凡是三维视图中的方向、法向、切面或平移提示,都应从 Three.js 真实对象矩阵或统一坐标变换链路推导,不能手写静态示意。若该提示会随拖拽视角变化,还必须包含场景根节点和相机投影。 + +## 2026-05-20-02-32-47 分割影像与类别元数据必须同包同源 + +A. 具体问题 + +用户要求“导出全部 NII.GZ”改成压缩包形式,同时分割影像必须附带不同类别 ID 与名称的对应 JSON,并且分割范围可选所有类别或可见类别。如果 NIfTI 和 JSON 分开下载,或二者使用不同筛选条件,后续在 ITK-SNAP 查看时容易无法追溯标签语义。 + +B. 产生问题原因 + +旧前端对多个导出目标采用连续触发多个直链下载,缺少一个原子化导出包;旧分割生成逻辑默认跳过隐藏构件,也没有把实际参与导出的 label/partId/name/fileName/color 等元数据写入侧车文件。 + +C. 解决问题方案 + +新增 `/api/projects/:projectId/export-bundle`,将 DICOM NIfTI、分割 NIfTI、位姿 JSON 和分割 labels JSON 放入同一个 `.tar.gz`。分割生成函数和 labels JSON 生成函数共用 `segmentationScope=visible|all`,确保可见/全量筛选逻辑一致;labels JSON 中记录 label、partId、name、categoryName、className、fileName、color、opacity、visible 和 activePose。 + +D. 后续如何避免问题 + +任何分割影像导出都应同时考虑语义侧车文件,并保证侧车元数据与实际 mask 标签来自同一批样式和筛选条件。多文件导出优先做成一个后端归档包,避免浏览器多下载顺序、丢文件或元数据错配。 diff --git a/工程分析/需求分析-2026-05-20-02-32-47.md b/工程分析/需求分析-2026-05-20-02-32-47.md new file mode 100644 index 0000000..c07c5b7 --- /dev/null +++ b/工程分析/需求分析-2026-05-20-02-32-47.md @@ -0,0 +1,56 @@ +# 需求分析-2026-05-20-02-32-47 + +## 开始时间 + +2026-05-20-02-32-47 + +## 原始需求摘要 + +用户要求优化“导出全部 NII.GZ”: + +- 选择导出全部时,改为压缩包形式一次导出。 +- 如果选择分割影像,需要同时导出不同类别 ID 与名称/类别名对应的 JSON 文件。 +- 分割影像导出范围可选择“所有类别”或“可见类别”。 + +## 业务目标 + +- 避免多文件连续触发下载,形成一个可归档、可转移的导出包。 +- 让分割影像与类别语义元数据成对导出,便于 ITK-SNAP 查看后再对照系统内 STL 构件层级。 +- 保留“只导出当前可见构件”的轻量检查场景,同时支持“导出全部构件”的完整交付场景。 + +## 输入与输出 + +- 输入: + - 导出内容选择:DICOM 原始影像、分割影像、位姿数据。 + - 分割类别范围:所有类别或可见类别。 + - 当前模型位姿。 +- 输出: + - 一个 `.tar.gz` 导出包。 + - 包内按选择包含 DICOM NIfTI、分割 NIfTI、位姿 JSON。 + - 若包含分割影像,同时包含类别映射 JSON。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- 本次工程分析文档和经验记录。 + +## 关键约束 + +- NIfTI 导出仍必须使用真实 DICOM 维度、spacing 和当前模型位姿。 +- 分割类别范围不能影响 DICOM 原始影像和位姿数据导出。 +- 类别 JSON 必须与实际导出的分割标签值一致。 +- 前端仍使用后端直链下载,避免重新引入 blob URL。 + +## 风险点 + +- DICOM NIfTI 和分割 NIfTI 都可能较大,包生成会有一定耗时。 +- 当前项目已有 tar.gz 归档工具,若强行实现 zip 可能引入额外复杂度;本轮默认使用 `.tar.gz` 压缩包。 +- “可见类别”需要读取项目持久化的 `moduleStyles.visible`,不能只看前端临时状态。 + +## 默认假设 + +- “压缩包形式”按项目现有归档方式实现为 `.tar.gz`。 +- 顶部“导出全部 NII.GZ”使用压缩包;右侧单独 NII/NII.GZ 导出按钮继续导出单个分割 NIfTI。 +- 分割类别范围默认为“可见类别”,符合当前右侧映射视图的显示语义。