diff --git a/Docker部署/README.md b/Docker部署/README.md index 8dacef0..1bf77aa 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -19,6 +19,7 @@ - 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。 - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 +- 逆向分割映射视图按当前可见构件加载高精度 STL 预览;“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 ## 一、本机部署 diff --git a/WebSite/server.ts b/WebSite/server.ts index 039dc30..bc408d7 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -142,6 +142,7 @@ const dicomVolumeCache = new Map(); const modelPreviewCache = new Map(); const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; +const maxPreviewTriangles = 500000; const defaultModelPose: ModelPoseValue = { rotateX: 0, rotateY: 0, @@ -1601,6 +1602,22 @@ function parseModelPoseQuery(raw: unknown) { } } +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>; + } catch { + return undefined; + } +} + function parseSegmentationScope(raw: unknown): SegmentationExportScope { return raw === 'all' ? 'all' : 'visible'; } @@ -1613,15 +1630,20 @@ function latestSegmentationResult(project: ProjectRecord) { return project.segmentationResults?.[project.segmentationResults.length - 1]; } -function projectWithSegmentationResultStyles(project: ProjectRecord): ProjectRecord { +function projectWithSegmentationResultStyles( + project: ProjectRecord, + requestedModuleStyles?: Record>, +): ProjectRecord { const latestResult = latestSegmentationResult(project); - if (!latestResult) { - return project; - } + const mergedStyles = buildModuleStyles(project.stlFiles, { + ...(latestResult?.moduleStyles ?? {}), + ...(project.moduleStyles ?? {}), + ...(requestedModuleStyles ?? {}), + }); return { ...project, - moduleStyles: latestResult.moduleStyles, + moduleStyles: mergedStyles, }; } @@ -2385,7 +2407,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number): Mo throw new Error('当前仅支持二进制 STL 预览'); } - const sampleLimit = Math.max(100, Math.min(limit, 200000)); + const sampleLimit = Math.max(100, Math.min(limit, maxPreviewTriangles)); const step = Math.max(1, Math.ceil(triangleCount / sampleLimit)); const vertices: number[] = []; let sampledTriangles = 0; @@ -3234,7 +3256,7 @@ async function startServer() { const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation'); const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation'; - const exportProject = projectWithSegmentationResultStyles(project); + 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 @@ -3286,7 +3308,7 @@ async function startServer() { return; } - const exportProject = projectWithSegmentationResultStyles(project); + 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 diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 6ec0ec6..41ef090 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -981,7 +981,9 @@ export default function ProjectLibrary({ const savedSegmentationResults = selectedProject?.segmentationResults ?? []; const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; const latestResultPose = latestSegmentationResult ? resultPose : modelPose; - const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles; + const latestResultStyles = latestSegmentationResult + ? { ...latestSegmentationResult.moduleStyles, ...moduleStyles } + : moduleStyles; const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice)); const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0]; @@ -1030,6 +1032,7 @@ export default function ProjectLibrary({ pose: latestSegmentationResult?.pose ?? modelPose, segmentationScope: maskSegmentationScope, segmentationExportMode: maskSegmentationExportMode, + moduleStyles, }); window.setTimeout(() => setMaskExporting(false), 900); setShowMaskExportMenu(false); @@ -1163,7 +1166,10 @@ export default function ProjectLibrary({ const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); const next: Record = {}; stlFiles.forEach((fileName, index) => { - next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]); + next[fileName] = makeDefaultModuleStyle(index, { + ...(latestResult?.moduleStyles?.[fileName] ?? {}), + ...(selectedProject?.moduleStyles?.[fileName] ?? {}), + }); }); setModuleStyles(next); setSliceIndex(0); diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 694e2ab..a18580a 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -2171,7 +2171,9 @@ export function VoxelizationMappingView({ const maxSlice = Math.max(totalSlices - 1, 0); const safeSlice = clamp(slice, 0, maxSlice); const stlFiles = project?.stlFiles ?? []; - const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length; + const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false); + const visibleStlFileSignature = visibleStlFiles.join('|'); + const visibleModuleCount = visibleStlFiles.length; const isLibraryVariant = variant === 'library'; const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom'); @@ -2206,16 +2208,16 @@ export function VoxelizationMappingView({ }, [project?.id, project?.dicomCount, safeSlice, displayMode]); useEffect(() => { - if (!project || !stlFiles.length) { + if (!project || !visibleStlFiles.length) { setModelPreviews({}); - setOverlayStatus('当前项目没有 STL 构件'); + setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件'); return; } let disposed = false; - setOverlayStatus('正在载入 STL 构件层级...'); - Promise.allSettled(stlFiles.map((fileName) => ( - getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000)) + setOverlayStatus('正在载入可见 STL 构件层级...'); + Promise.allSettled(visibleStlFiles.map((fileName) => ( + getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 500000)) .then((payload) => ({ fileName, payload })) ))).then((results) => { if (disposed) return; @@ -2232,7 +2234,7 @@ export function VoxelizationMappingView({ return () => { disposed = true; }; - }, [project?.id, stlFiles.join('|'), detailLimit]); + }, [project?.id, stlFiles.length, visibleStlFileSignature, detailLimit]); useEffect(() => { const canvas = baseCanvasRef.current; @@ -2251,7 +2253,7 @@ export function VoxelizationMappingView({ const stats = drawVoxelOverlayLayer( canvas, dicomPreview, - stlFiles, + visibleStlFiles, modelPreviews, moduleStyles, modelPose, @@ -2264,7 +2266,7 @@ export function VoxelizationMappingView({ return () => window.cancelAnimationFrame(frame); }, [ dicomPreview, - stlFiles.join('|'), + visibleStlFileSignature, modelPreviews, JSON.stringify(moduleStyles), modelPose.rotateX, @@ -2638,6 +2640,7 @@ export default function ReverseWorkspace({ pose: modelPose, segmentationScope: segmentationExportScope, segmentationExportMode, + moduleStyles, }); window.setTimeout(() => setExporting(false), 900); setShowExportMenu(false); @@ -2947,7 +2950,10 @@ export default function ReverseWorkspace({ setPoseValueDrafts(formatPoseDraftValues(restoredPose)); const nextStyles: Record = {}; (item.stlFiles ?? []).forEach((fileName, index) => { - nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? item.moduleStyles?.[fileName]); + nextStyles[fileName] = makeDefaultModuleStyle(index, { + ...(latestResult?.moduleStyles?.[fileName] ?? {}), + ...(item.moduleStyles?.[fileName] ?? {}), + }); }); setModuleStyles(nextStyles); setSavedPoses(nextPoses); diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 1ed2c0e..6fbc890 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -203,7 +203,13 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 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; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) { +function appendModuleStyles(params: URLSearchParams, moduleStyles?: Record) { + if (moduleStyles) { + params.set('moduleStyles', JSON.stringify(moduleStyles)); + } +} + +export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record } = {}) { const params = new URLSearchParams({ target, format }); if (target === 'segmentation' || target === 'pose') { appendPose(params, options.pose); @@ -211,11 +217,12 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx if (target === 'segmentation') { params.set('segmentationScope', options.segmentationScope ?? 'visible'); params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined'); + appendModuleStyles(params, options.moduleStyles); } triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`); } -export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) { +export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record } = {}) { const params = new URLSearchParams({ targets: targets.join(','), format, @@ -223,6 +230,7 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr segmentationExportMode: options.segmentationExportMode ?? 'combined', }); appendPose(params, options.pose); + appendModuleStyles(params, options.moduleStyles); triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`); } diff --git a/工程分析/实现方案-2026-05-24-16-28-58.md b/工程分析/实现方案-2026-05-24-16-28-58.md new file mode 100644 index 0000000..dfb8101 --- /dev/null +++ b/工程分析/实现方案-2026-05-24-16-28-58.md @@ -0,0 +1,59 @@ +# 实现方案-2026-05-24-16-28-58 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-24-16-28-58.md` + +## 修改目标 + +- 重启并验证公网服务。 +- 修正 `visible` 导出范围被历史保存结果覆盖的问题。 +- 改进二维 overlay 和后端导出填充,减少实体区域内部条纹和薄结构线条感。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `Docker部署/README.md` +- `工程分析/经验记录.md` + +## 技术路线 + +- 先使用 `tmux` 重启 `revoxelseg-dicom`,验证本机与公网入口。 +- 排查导出链路,确认 `projectWithSegmentationResultStyles`、`segmentationScope` 与当前 `moduleStyles.visible` 的优先级。 +- 导出时让当前项目样式覆盖历史分割结果样式,确保最新眼睛状态参与过滤。 +- 必要时在前端导出请求中显式传递当前 `moduleStyles`,后端读取并归一化,避免状态同步延迟。 +- 对填充后的 mask 做轻量闭合与孤立小孔修补,前端 overlay 与后端 NIfTI 保持一致策略。 + +## 执行步骤 + +1. 已重启服务并验证公网、本机 HTTP 200。 +2. 阅读当前导出和填充代码,定位可见构件过滤来源。 +3. 修正导出样式优先级或新增导出样式参数。 +4. 改进实体化填充策略,重点避免可见大块区域内部横向空线。 +5. 同步 Docker 文档和经验记录。 +6. 执行 `npm run lint`、`npm run build`。 +7. 用项目 `123` 执行可见类别分别导出,检查 tar 列表只包含可见构件。 +8. 重启部署,验证公网与本机。 +9. 提交并推送 Gitea。 + +## 兼容性与回滚方案 + +- 如果前端未传 `moduleStyles`,后端回退到项目当前样式与最新结果合并。 +- 如果实体化修补导致过度连接,可回滚到仅小缝闭合和按连通组填充。 +- 回滚时移除新增参数读取即可,不影响旧导出 API。 + +## 预计文件变更 + +- 新增本次三份工程分析文档。 +- 修改后端导出样式选择与 mask 修补。 +- 修改前端导出请求参数。 +- 修改 Docker 部署说明与经验记录。 + +## 提交与部署策略 + +- 只暂存本次相关源码、Docker 文档和工程分析文档。 +- Commit message 包含 `2026-05-24-16-28-58`。 +- 部署使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。 diff --git a/工程分析/测试方案-2026-05-24-16-28-58.md b/工程分析/测试方案-2026-05-24-16-28-58.md new file mode 100644 index 0000000..181b959 --- /dev/null +++ b/工程分析/测试方案-2026-05-24-16-28-58.md @@ -0,0 +1,57 @@ +# 测试方案-2026-05-24-16-28-58 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-24-16-28-58.md` + +## 静态检查 + +- 在 `WebSite/` 执行 `npm run lint`。 + +## 构建检查 + +- 在 `WebSite/` 执行 `npm run build`。 + +## 关键业务场景验证 + +- 公网 `https://revoxel.huijutec.cn/` 和 `/api/health` 可访问。 +- 只显示 `liver_artery`、`liver_right` 时,选择“可见类别 + 构件分别导出”,导出包 `segmentation-parts/` 只包含这两个构件。 +- 已隐藏构件不出现在 `segmentation-parts/` 目录中。 +- overlay 仍只统计当前可见构件。 + +## 医学影像数据相关边界验证 + +- `liver_artery` 作为细长血管结构允许保持窄形态,但不应由导出算法额外生成跨区域长线桥。 +- `liver_right` 大区域内部不应因为扫描线小缝形成大量空条纹。 +- 分割修补不应跨断开连通组全局连接。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证 `http://127.0.0.1:4000/api/health`。 +- 验证 `http://127.0.0.1:4000/`。 +- 验证 `https://revoxel.huijutec.cn/api/health`。 +- 验证 `https://revoxel.huijutec.cn/`。 + +## Git/Gitea 备份验证 + +- `git status --short` 检查无关运行态文件未被暂存。 +- Commit message 包含 `2026-05-24-16-28-58`。 +- 推送到 `origin/main` 并确认本地远端同步。 + +## 风险与回归关注点 + +- 不把用户当前工作区可见状态误写死为全局默认。 +- 不让历史 `segmentationResults` 覆盖用户刚刚点击的眼睛状态。 +- 不把导出测试生成的 `.tar.gz`、`.nii.gz` 混入 Git。 + +## 执行结果 + +- 重启前后 `https://revoxel.huijutec.cn/` 与 `/api/health` 均返回 HTTP 200。 +- `npm run lint`:通过。 +- `npm run build`:通过;仅有 Vite chunk size 常规提示。 +- `liver_right.stl` 高精度预览验证:`triangleCount=297268`、`sampledTriangles=297268`。 +- `liver_artery.stl` 高精度预览验证:`triangleCount=220920`、`sampledTriangles=220920`。 +- 项目 `123` 可见类别分别导出验证通过:`segmentation-parts/` 仅包含 `004-liver_artery-label.nii.gz`、`006-liver_right-label.nii.gz` 和 labels.json,不再导出 `007/008/009`。 +- 本机 `http://127.0.0.1:4000/api/health`:HTTP 200。 +- 公网 `https://revoxel.huijutec.cn/api/health`:HTTP 200。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index cfd2942..b9dfc36 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1711,3 +1711,21 @@ C. 解决问题方案 D. 后续如何避免问题 凡是新增项目运行态数据,都要同时考虑后端状态归一化、前端类型、API、列表排序、Docker 持久化挂载和 `.gitignore`。锁定类快照属于运行态追溯数据,不应混入源码 commit。DICOM 切片控件要明确区分“数组索引”“用户显示层号”和“滑块位置”,不要用同一个变量同时表达三种语义。 + +## 2026-05-24-16-28-58 导出可见类别必须以当前项目样式为准 + +A. 具体问题 + +用户只显示 `liver_artery` 和 `liver_right` 后,选择“可见类别 + 构件分别导出”,导出包中仍出现 `007-liver_segment_S1-label.nii.gz`、`008-liver_segment_S2-label.nii.gz`、`009-liver_segment_S3-label.nii.gz`。同时,逆向分割映射中 `liver_right` 大区域出现条纹和异常形状,`liver_artery` 仍显得像多条线。 + +B. 产生问题原因 + +项目当前 `moduleStyles` 中 `liver_artery`、`liver_right` 是可见,`S1/S2/S3` 是隐藏;但最新一次保存的 `segmentationResults` 中可见状态正好相反。旧导出逻辑使用历史保存结果的 `moduleStyles` 覆盖项目当前样式,导致“可见类别”按旧状态导出。二维 overlay 另一个问题是 STL 预览最多抽样 20 万三角面,而 `liver_right` 有约 29.7 万面、`liver_artery` 有约 22.1 万面,抽样会缺失一部分切面轮廓,表现为条纹或形状不完整。血管本身也属于细长管状结构,真实切面不会像肝叶一样形成单一大块。 + +C. 解决问题方案 + +导出时合并样式优先级改为:历史保存结果作为 fallback,项目当前 `moduleStyles` 覆盖历史结果,前端导出请求中传入的当前 `moduleStyles` 再覆盖后端状态。这样即使用户刚点击眼睛后立刻导出,也按当前可见状态过滤。逆向分割映射只加载当前可见构件,并把可见构件预览上限提高到 50 万三角面;`liver_right` 与 `liver_artery` 在项目 `123` 中均可全量取面。Docker 文档同步说明“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 + +D. 后续如何避免问题 + +凡是导出“当前可见”这类 UI 状态,不能只依赖历史保存结果;后端应以项目当前状态为准,前端最好显式携带本次导出的关键状态,防止异步保存延迟。二维 overlay 如果用于判断实体形状,必须确认 STL 预览不是抽样缺面;对超过预览上限的大模型,应按可见构件高精度加载或在界面提示。细血管、胆管等管状结构应解释为“天然多小截面/窄带”,不要把所有结构都承诺成单一大实体块。 diff --git a/工程分析/需求分析-2026-05-24-16-28-58.md b/工程分析/需求分析-2026-05-24-16-28-58.md new file mode 100644 index 0000000..cf99bee --- /dev/null +++ b/工程分析/需求分析-2026-05-24-16-28-58.md @@ -0,0 +1,47 @@ +# 需求分析-2026-05-24-16-28-58 + +## 开始时间 + +2026-05-24-16-28-58 + +## 原始需求摘要 + +1. 先重启程序,并确认 `https://revoxel.huijutec.cn/` 是否可访问。 +2. 解释并修正逆向工作区中 `liver_right` 显示形状异常、`liver_artery` 仍像多条线构成而非实体区域的问题。 +3. 修正导出:用户只显示 `liver_artery`、`liver_right` 时,`segmentation-parts/` 不应导出隐藏的 `liver_segment_S1/S2/S3`。 + +## 业务目标 + +- 保证公网服务恢复可访问。 +- 让二维分割映射和导出的 NIfTI 更贴近“实体区域”预期,减少条纹感和异常桥接。 +- 让“可见类别 + 构件分别导出”严格只导出当前可见构件,便于后处理。 + +## 输入与输出 + +- 输入:项目当前构件可见状态、模型位姿、导出选项、DICOM 切片。 +- 输出:公网可访问服务、修正后的 overlay 显示、只包含可见构件的 `segmentation-parts/` 导出包。 + +## 影响范围 + +- 前端:逆向工作区 overlay 显示、导出参数传递。 +- 后端:项目导出时构件样式来源、`visible` 范围过滤、STL 切面填充策略。 +- 文档:`Docker部署/` 和 `工程分析/经验记录.md`。 + +## 关键约束 + +- 不伪造临床级分割能力;真实显示仍受 STL 几何、模型位姿和体素化精度影响。 +- 当前可见性必须以用户最新 UI 操作后的 `project.moduleStyles` 为准,不能被历史 `segmentationResults` 覆盖。 +- 修正实体填充不能重新引入跨断开轮廓长桥。 +- 运行态导出文件、锁定结果和医学数据不混入提交。 + +## 风险点 + +- `liver_artery` 是细长血管结构,真实几何可能天然呈细线/窄带,过度膨胀会改变语义。 +- `liver_right` 若受当前位姿、镜像或切片位置影响,二维切面可能只截到局部区域,需要区分显示算法问题和配准位置问题。 +- 导出包验证需要确认 tar 内文件名,而不只是前端菜单状态。 + +## 默认假设 + +- 用户选择“可见类别”时,期望导出当前眼睛打开的构件。 +- “构件分别导出”仍应把所有导出的可见构件放入同一个 `segmentation-parts/` 目录。 +- 对血管等细结构允许轻量实体化增厚,但不做大半径形态学膨胀。