2026-05-24-16-28-58 修正可见构件导出与高精度映射
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。
|
- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。
|
||||||
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
||||||
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
||||||
|
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览;“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
||||||
|
|
||||||
## 一、本机部署
|
## 一、本机部署
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
工程分析/实现方案-2026-05-24-16-28-58.md
Normal file
59
工程分析/实现方案-2026-05-24-16-28-58.md
Normal 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`。
|
||||||
57
工程分析/测试方案-2026-05-24-16-28-58.md
Normal file
57
工程分析/测试方案-2026-05-24-16-28-58.md
Normal 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。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -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 预览不是抽样缺面;对超过预览上限的大模型,应按可见构件高精度加载或在界面提示。细血管、胆管等管状结构应解释为“天然多小截面/窄带”,不要把所有结构都承诺成单一大实体块。
|
||||||
|
|||||||
47
工程分析/需求分析-2026-05-24-16-28-58.md
Normal file
47
工程分析/需求分析-2026-05-24-16-28-58.md
Normal 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/` 目录。
|
||||||
|
- 对血管等细结构允许轻量实体化增厚,但不做大半径形态学膨胀。
|
||||||
Reference in New Issue
Block a user