2026-05-24-16-28-58 修正可见构件导出与高精度映射

This commit is contained in:
2026-05-24 16:42:37 +08:00
parent 3bedf204c8
commit d9572e6966
9 changed files with 246 additions and 22 deletions

View File

@@ -19,6 +19,7 @@
- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。 - 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/` - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览;“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
## 一、本机部署 ## 一、本机部署

View File

@@ -142,6 +142,7 @@ const dicomVolumeCache = new Map<string, {
}>(); }>();
const modelPreviewCache = new Map<string, unknown>(); const modelPreviewCache = new Map<string, unknown>();
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const maxPreviewTriangles = 500000;
const defaultModelPose: ModelPoseValue = { const defaultModelPose: ModelPoseValue = {
rotateX: 0, rotateX: 0,
rotateY: 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<string, Partial<ModuleStyleRecord>>;
} catch {
return undefined;
}
}
function parseSegmentationScope(raw: unknown): SegmentationExportScope { function parseSegmentationScope(raw: unknown): SegmentationExportScope {
return raw === 'all' ? 'all' : 'visible'; return raw === 'all' ? 'all' : 'visible';
} }
@@ -1613,15 +1630,20 @@ function latestSegmentationResult(project: ProjectRecord) {
return project.segmentationResults?.[project.segmentationResults.length - 1]; return project.segmentationResults?.[project.segmentationResults.length - 1];
} }
function projectWithSegmentationResultStyles(project: ProjectRecord): ProjectRecord { function projectWithSegmentationResultStyles(
project: ProjectRecord,
requestedModuleStyles?: Record<string, Partial<ModuleStyleRecord>>,
): ProjectRecord {
const latestResult = latestSegmentationResult(project); const latestResult = latestSegmentationResult(project);
if (!latestResult) { const mergedStyles = buildModuleStyles(project.stlFiles, {
return project; ...(latestResult?.moduleStyles ?? {}),
} ...(project.moduleStyles ?? {}),
...(requestedModuleStyles ?? {}),
});
return { return {
...project, ...project,
moduleStyles: latestResult.moduleStyles, moduleStyles: mergedStyles,
}; };
} }
@@ -2385,7 +2407,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number): Mo
throw new Error('当前仅支持二进制 STL 预览'); 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 step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = []; const vertices: number[] = [];
let sampledTriangles = 0; let sampledTriangles = 0;
@@ -3234,7 +3256,7 @@ async function startServer() {
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation'); const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : '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 latestResult = latestSegmentationResult(project);
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose; const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
const segmentationScope = req.query.segmentationScope === undefined const segmentationScope = req.query.segmentationScope === undefined
@@ -3286,7 +3308,7 @@ async function startServer() {
return; return;
} }
const exportProject = projectWithSegmentationResultStyles(project); const exportProject = projectWithSegmentationResultStyles(project, parseModuleStylesQuery(req.query.moduleStyles));
const latestResult = latestSegmentationResult(project); const latestResult = latestSegmentationResult(project);
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose; const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
const segmentationScope = req.query.segmentationScope === undefined const segmentationScope = req.query.segmentationScope === undefined

View File

@@ -981,7 +981,9 @@ export default function ProjectLibrary({
const savedSegmentationResults = selectedProject?.segmentationResults ?? []; const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
const latestResultPose = latestSegmentationResult ? resultPose : modelPose; 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 resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice)); const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice));
const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0]; const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0];
@@ -1030,6 +1032,7 @@ export default function ProjectLibrary({
pose: latestSegmentationResult?.pose ?? modelPose, pose: latestSegmentationResult?.pose ?? modelPose,
segmentationScope: maskSegmentationScope, segmentationScope: maskSegmentationScope,
segmentationExportMode: maskSegmentationExportMode, segmentationExportMode: maskSegmentationExportMode,
moduleStyles,
}); });
window.setTimeout(() => setMaskExporting(false), 900); window.setTimeout(() => setMaskExporting(false), 900);
setShowMaskExportMenu(false); setShowMaskExportMenu(false);
@@ -1163,7 +1166,10 @@ export default function ProjectLibrary({
const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const next: Record<string, ModuleStyle> = {}; const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => { 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); setModuleStyles(next);
setSliceIndex(0); setSliceIndex(0);

View File

@@ -2171,7 +2171,9 @@ export function VoxelizationMappingView({
const maxSlice = Math.max(totalSlices - 1, 0); const maxSlice = Math.max(totalSlices - 1, 0);
const safeSlice = clamp(slice, 0, maxSlice); const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? []; 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 isLibraryVariant = variant === 'library';
const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom'); const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom');
@@ -2206,16 +2208,16 @@ export function VoxelizationMappingView({
}, [project?.id, project?.dicomCount, safeSlice, displayMode]); }, [project?.id, project?.dicomCount, safeSlice, displayMode]);
useEffect(() => { useEffect(() => {
if (!project || !stlFiles.length) { if (!project || !visibleStlFiles.length) {
setModelPreviews({}); setModelPreviews({});
setOverlayStatus('当前项目没有 STL 构件'); setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
return; return;
} }
let disposed = false; let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...'); setOverlayStatus('正在载入可见 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => ( Promise.allSettled(visibleStlFiles.map((fileName) => (
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000)) getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 500000))
.then((payload) => ({ fileName, payload })) .then((payload) => ({ fileName, payload }))
))).then((results) => { ))).then((results) => {
if (disposed) return; if (disposed) return;
@@ -2232,7 +2234,7 @@ export function VoxelizationMappingView({
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [project?.id, stlFiles.join('|'), detailLimit]); }, [project?.id, stlFiles.length, visibleStlFileSignature, detailLimit]);
useEffect(() => { useEffect(() => {
const canvas = baseCanvasRef.current; const canvas = baseCanvasRef.current;
@@ -2251,7 +2253,7 @@ export function VoxelizationMappingView({
const stats = drawVoxelOverlayLayer( const stats = drawVoxelOverlayLayer(
canvas, canvas,
dicomPreview, dicomPreview,
stlFiles, visibleStlFiles,
modelPreviews, modelPreviews,
moduleStyles, moduleStyles,
modelPose, modelPose,
@@ -2264,7 +2266,7 @@ export function VoxelizationMappingView({
return () => window.cancelAnimationFrame(frame); return () => window.cancelAnimationFrame(frame);
}, [ }, [
dicomPreview, dicomPreview,
stlFiles.join('|'), visibleStlFileSignature,
modelPreviews, modelPreviews,
JSON.stringify(moduleStyles), JSON.stringify(moduleStyles),
modelPose.rotateX, modelPose.rotateX,
@@ -2638,6 +2640,7 @@ export default function ReverseWorkspace({
pose: modelPose, pose: modelPose,
segmentationScope: segmentationExportScope, segmentationScope: segmentationExportScope,
segmentationExportMode, segmentationExportMode,
moduleStyles,
}); });
window.setTimeout(() => setExporting(false), 900); window.setTimeout(() => setExporting(false), 900);
setShowExportMenu(false); setShowExportMenu(false);
@@ -2947,7 +2950,10 @@ export default function ReverseWorkspace({
setPoseValueDrafts(formatPoseDraftValues(restoredPose)); setPoseValueDrafts(formatPoseDraftValues(restoredPose));
const nextStyles: Record<string, ModuleStyle> = {}; const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => { (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); setModuleStyles(nextStyles);
setSavedPoses(nextPoses); setSavedPoses(nextPoses);

View File

@@ -203,7 +203,13 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`); 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<string, ModuleStyle>) {
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<string, ModuleStyle> } = {}) {
const params = new URLSearchParams({ target, format }); const params = new URLSearchParams({ target, format });
if (target === 'segmentation' || target === 'pose') { if (target === 'segmentation' || target === 'pose') {
appendPose(params, options.pose); appendPose(params, options.pose);
@@ -211,11 +217,12 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx
if (target === 'segmentation') { if (target === 'segmentation') {
params.set('segmentationScope', options.segmentationScope ?? 'visible'); params.set('segmentationScope', options.segmentationScope ?? 'visible');
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined'); params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
appendModuleStyles(params, options.moduleStyles);
} }
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`); 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<string, ModuleStyle> } = {}) {
const params = new URLSearchParams({ const params = new URLSearchParams({
targets: targets.join(','), targets: targets.join(','),
format, format,
@@ -223,6 +230,7 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr
segmentationExportMode: options.segmentationExportMode ?? 'combined', segmentationExportMode: options.segmentationExportMode ?? 'combined',
}); });
appendPose(params, options.pose); appendPose(params, options.pose);
appendModuleStyles(params, options.moduleStyles);
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`); triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
} }

View File

@@ -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`

View File

@@ -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。

View File

@@ -1711,3 +1711,21 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
凡是新增项目运行态数据都要同时考虑后端状态归一化、前端类型、API、列表排序、Docker 持久化挂载和 `.gitignore`。锁定类快照属于运行态追溯数据,不应混入源码 commit。DICOM 切片控件要明确区分“数组索引”“用户显示层号”和“滑块位置”,不要用同一个变量同时表达三种语义。 凡是新增项目运行态数据都要同时考虑后端状态归一化、前端类型、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 预览不是抽样缺面;对超过预览上限的大模型,应按可见构件高精度加载或在界面提示。细血管、胆管等管状结构应解释为“天然多小截面/窄带”,不要把所有结构都承诺成单一大实体块。

View File

@@ -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/` 目录。
- 对血管等细结构允许轻量实体化增厚,但不做大半径形态学膨胀。