2026-05-24-18-59-49 修正薄壳构件实体化映射

This commit is contained in:
2026-05-24 19:10:02 +08:00
parent 1dcfc2a4c1
commit 21b04ecffd
8 changed files with 337 additions and 20 deletions

View File

@@ -12,14 +12,14 @@
当前 Docker 构建会同步包含以下能力: 当前 Docker 构建会同步包含以下能力:
- 二维逆向分割映射按实体填充显示,导出的分割 Label Map 也按填充区域写入。 - 二维逆向分割映射按实体填充显示,rib、skin 等薄壳或细长构件会做局部实体化兜底,导出的分割 Label Map 也按填充区域写入。
- 模型位姿支持以模型中心沿 X/Y/Z 轴镜像翻转,保存、项目库预览和导出均沿用该位姿。 - 模型位姿支持以模型中心沿 X/Y/Z 轴镜像翻转,保存、项目库预览和导出均沿用该位姿。
- “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。 - “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。
- 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。 - 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。
- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。 - 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/` - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览;“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 - 逆向分割映射视图按当前可见构件加载高精度 STL 预览;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
## 一、本机部署 ## 一、本机部署
@@ -145,3 +145,4 @@ npm run serve -- --host 0.0.0.0 --port 4000
- 逆向工作区切换构件层级时,可见 STL 构件会显示加载进度,避免高精度预览加载期间误判为构件不显示。 - 逆向工作区切换构件层级时,可见 STL 构件会显示加载进度,避免高精度预览加载期间误判为构件不显示。
- DICOM 与逆向分割映射画布的滚轮缩放使用非被动 wheel 监听,修正浏览器控制台 `Unable to preventDefault inside passive event listener invocation` 警告。 - DICOM 与逆向分割映射画布的滚轮缩放使用非被动 wheel 监听,修正浏览器控制台 `Unable to preventDefault inside passive event listener invocation` 警告。
- rib、skin 等薄壳或细长构件在实体模式和逆向分割映射视图中使用更高精度 STL 预览,并增加局部厚度填充,避免只显示散点或单像素线段。

View File

@@ -142,7 +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 maxPreviewTriangles = 800000;
const defaultModelPose: ModelPoseValue = { const defaultModelPose: ModelPoseValue = {
rotateX: 0, rotateX: 0,
rotateY: 0, rotateY: 0,
@@ -1288,7 +1288,76 @@ function closeExportMaskGaps(mask: Uint8Array, width: number, height: number, ma
return toFill.size; return toFill.size;
} }
function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) { function exportSolidStrokeRadius(width: number, height: number) {
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
}
function paintExportMaskPixel(mask: Uint8Array, width: number, height: number, x: number, y: number) {
if (x < 0 || x >= width || y < 0 || y >= height) {
return 0;
}
const index = y * width + x;
if (mask[index]) {
return 0;
}
mask[index] = 1;
return 1;
}
function fillExportSegmentCapsules(
mask: Uint8Array,
width: number,
height: number,
segments: PlaneSegmentRecord[],
radius: number,
) {
let paintedPixels = 0;
const radiusSquared = radius * radius;
segments.forEach(({ a, b }) => {
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
return;
}
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const minX = clampNumber(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
const maxX = clampNumber(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
const minY = clampNumber(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
const maxY = clampNumber(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const t = lengthSquared <= 1e-6
? 0
: clampNumber(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
const closestX = a.x + dx * t;
const closestY = a.y + dy * t;
const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
if (distanceSquared <= radiusSquared) {
paintedPixels += paintExportMaskPixel(mask, width, height, x, y);
}
}
}
});
return paintedPixels;
}
function fillExportRows(
data: Buffer,
width: number,
height: number,
slice: number,
rows: number[][],
label: number,
solidSegments: PlaneSegmentRecord[] = [],
) {
const mask = new Uint8Array(width * height); const mask = new Uint8Array(width * height);
let filledPixels = 0; let filledPixels = 0;
@@ -1325,11 +1394,15 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
} }
}); });
if (solidSegments.length) {
filledPixels += fillExportSegmentCapsules(mask, width, height, solidSegments, exportSolidStrokeRadius(width, height));
}
if (filledPixels === 0) { if (filledPixels === 0) {
return 0; return 0;
} }
filledPixels += closeExportMaskGaps(mask, width, height); filledPixels += closeExportMaskGaps(mask, width, height, 3);
filledPixels += fillExportInternalHoles(mask, width, height); filledPixels += fillExportInternalHoles(mask, width, height);
const sliceOffset = slice * width * height; const sliceOffset = slice * width * height;
for (let index = 0; index < mask.length; index += 1) { for (let index = 0; index < mask.length; index += 1) {
@@ -1527,11 +1600,11 @@ function createSegmentationData(
}); });
slicesByIndex.forEach(({ segments }, slice) => { slicesByIndex.forEach(({ segments }, slice) => {
groupExportSegmentsByConnectivity(segments).forEach((group) => { groupExportSegmentsByConnectivity(segments, exportSolidStrokeRadius(volume.width, volume.height) * 1.15).forEach((group) => {
const rows = Array.from({ length: volume.height }, () => [] as number[]); const rows = Array.from({ length: volume.height }, () => [] as number[]);
group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment)); group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment));
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label); const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label, group);
if (filledPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) { if (filledPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label); fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label);
} }
}); });

View File

@@ -91,7 +91,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }
{ id: 'standard', label: '标准', limit: 16000 }, { id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 }, { id: 'fine', label: '精细', limit: 36000 },
{ id: 'ultra', label: '超精细', limit: 72000 }, { id: 'ultra', label: '超精细', limit: 72000 },
{ id: 'solid', label: '实体', limit: 200000 }, { id: 'solid', label: '实体', limit: 800000 },
]; ];
const defaultModelPose: ModelPose = { const defaultModelPose: ModelPose = {
rotateX: 0, rotateX: 0,
@@ -703,22 +703,24 @@ function NativeStlViewer({
let loaded = 0; let loaded = 0;
let failed = 0; let failed = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
visibleFiles.forEach((fileName) => { visibleFiles.forEach((fileName) => {
getCachedModelPreview(projectId, fileName, detailLimit) getCachedModelPreview(projectId, fileName, modelPreviewLimit)
.then((payload) => { .then((payload) => {
if (disposed) return; if (disposed) return;
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals(); geometry.computeVertexNormals();
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 }; const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 };
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity; const materialOpacity = solidMode ? 1 : style.opacity;
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
geometry, geometry,
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color: style.color, color: style.color,
opacity: materialOpacity, opacity: materialOpacity,
transparent: materialOpacity < 1, transparent: materialOpacity < 1,
depthWrite: materialOpacity >= 1,
roughness: solidMode ? 0.56 : 0.42, roughness: solidMode ? 0.56 : 0.42,
metalness: 0.04, metalness: 0.04,
side: THREE.DoubleSide, side: THREE.DoubleSide,

View File

@@ -70,7 +70,7 @@ export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: num
{ id: 'standard', label: '标准', limit: 16000 }, { id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 }, { id: 'fine', label: '精细', limit: 36000 },
{ id: 'ultra', label: '超精细', limit: 72000 }, { id: 'ultra', label: '超精细', limit: 72000 },
{ id: 'solid', label: '实体', limit: 200000 }, { id: 'solid', label: '实体', limit: 800000 },
]; ];
export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [ export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [
{ id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 }, { id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 },
@@ -707,13 +707,14 @@ export function FusionThreeView({
const stlFiles = project.stlFiles ?? []; const stlFiles = project.stlFiles ?? [];
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false); const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
let modelBaseScale = 1; let modelBaseScale = 1;
let loadedModels = 0; let loadedModels = 0;
let failedModels = 0; let failedModels = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
Promise.allSettled(stlFiles.map((fileName, index) => ( Promise.allSettled(stlFiles.map((fileName, index) => (
getCachedModelPreview(project.id, fileName, detailLimit) getCachedModelPreview(project.id, fileName, modelPreviewLimit)
.then((payload) => { .then((payload) => {
if (disposed) return; if (disposed) return;
const style = moduleStyles[fileName] ?? { const style = moduleStyles[fileName] ?? {
@@ -732,11 +733,12 @@ export function FusionThreeView({
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals(); geometry.computeVertexNormals();
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity; const materialOpacity = solidMode ? 1 : style.opacity;
const material = new THREE.MeshStandardMaterial({ const material = new THREE.MeshStandardMaterial({
color: style.color, color: style.color,
transparent: true, transparent: materialOpacity < 1,
opacity: materialOpacity, opacity: materialOpacity,
depthWrite: materialOpacity >= 1,
roughness: solidMode ? 0.56 : 0.48, roughness: solidMode ? 0.56 : 0.48,
metalness: 0.03, metalness: 0.03,
side: THREE.DoubleSide, side: THREE.DoubleSide,
@@ -1816,6 +1818,80 @@ function closeSmallMaskGaps(
return toFill.size; return toFill.size;
} }
function solidStrokeRadius(width: number, height: number) {
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
}
function paintMaskPixel(
maskData: ImageData,
width: number,
height: number,
x: number,
y: number,
rgb: { r: number; g: number; b: number },
alpha: number,
) {
if (x < 0 || x >= width || y < 0 || y >= height) {
return 0;
}
const offset = (y * width + x) * 4;
if (maskData.data[offset + 3] > 0) {
return 0;
}
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
return 1;
}
function fillSegmentCapsulesIntoMask(
maskData: ImageData,
width: number,
height: number,
segments: PlaneSegment[],
rgb: { r: number; g: number; b: number },
alpha: number,
radius: number,
) {
let paintedPixels = 0;
const radiusSquared = radius * radius;
segments.forEach(({ a, b }) => {
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
return;
}
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const t = lengthSquared <= 1e-6
? 0
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
const closestX = a.x + dx * t;
const closestY = a.y + dy * t;
const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
if (distanceSquared <= radiusSquared) {
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
}
}
}
});
return paintedPixels;
}
function drawFallbackClosedRegion( function drawFallbackClosedRegion(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
width: number, width: number,
@@ -1912,7 +1988,8 @@ function fillSegmentsAsSolidMask(
} }
const maskData = maskContext.createImageData(width, height); const maskData = maskContext.createImageData(width, height);
let filledPixels = 0; let filledPixels = 0;
const groups = groupPlaneSegmentsByConnectivity(segments); const radius = solidStrokeRadius(width, height);
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
const fallbackGroups: PlaneSegment[][] = []; const fallbackGroups: PlaneSegment[][] = [];
groups.forEach((group) => { groups.forEach((group) => {
@@ -1959,14 +2036,15 @@ function fillSegmentsAsSolidMask(
} }
} }
}); });
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
filledPixels += groupPixels; filledPixels += groupPixels;
if (groupPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) { if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
fallbackGroups.push(group); fallbackGroups.push(group);
} }
}); });
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha); filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0); maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0); context.drawImage(maskCanvas, 0, 0);
@@ -2221,7 +2299,7 @@ export function VoxelizationMappingView({
let disposed = false; let disposed = false;
let loaded = 0; let loaded = 0;
const total = visibleStlFiles.length; const total = visibleStlFiles.length;
const previewLimit = Math.max(detailLimit, 500000); const previewLimit = Math.max(detailLimit, 800000);
const updateLoadProgress = (phase: string) => { const updateLoadProgress = (phase: string) => {
if (!disposed) { if (!disposed) {
setOverlayLoadState({ loading: true, loaded, total, phase }); setOverlayLoadState({ loading: true, loaded, total, phase });
@@ -3396,7 +3474,7 @@ export default function ReverseWorkspace({
const fusionStart = Math.min(displayStart, displayEnd); const fusionStart = Math.min(displayStart, displayEnd);
const fusionEnd = Math.max(displayStart, displayEnd); const fusionEnd = Math.max(displayStart, displayEnd);
const previewLimit = selectedDisplay.limit; const previewLimit = selectedDisplay.limit;
const mappingPreviewLimit = Math.max(previewLimit, 200000); const mappingPreviewLimit = Math.max(previewLimit, 800000);
const total = 2 + stlFilesForLoad.length * 2; const total = 2 + stlFilesForLoad.length * 2;
const startedAt = Date.now(); const startedAt = Date.now();
let loaded = 0; let loaded = 0;

View File

@@ -0,0 +1,56 @@
# 实现方案-2026-05-24-18-59-49
## 实现方案文档路径
`工程分析/实现方案-2026-05-24-18-59-49.md`
## 修改目标
- 修正 rib/skin 等薄壳或细长 STL 在逆向分割映射视图中的点状、线状显示。
- 前端 overlay 与服务端导出共用更强的实体化兜底策略。
- 部署后让当前项目重新使用新算法实时生成映射结果。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/server.ts`
- `Docker部署/README.md`
- `工程分析/经验记录.md`
## 技术路线
- 在已有连通组扫描线填充基础上增加“切面实体兜底”:
- 对每个构件、每个切片、每个连通组独立处理。
- 正常闭合轮廓继续使用扫描线填充。
- 对面积过小、仅点线、薄壳或断续线段的组,按线段周围生成局部厚度,并对端点/边界做圆形或方形膨胀。
- 最后做小缝隙闭合与内部孔洞填充。
- 同步服务端 NIfTI 导出,让下载结果与界面展示一致。
- 保持当前高精度 STL preview 与加载进度逻辑。
## 执行步骤
1. 定位前端 overlay 填充和后端导出填充函数。
2. 抽取或补充局部 thick stroke / dilation 兜底函数。
3. 在前端 `drawVoxelOverlayLayer` 的每个连通组处理中应用实体兜底。
4. 在后端导出 mask 生成中应用相同实体兜底。
5. 执行类型检查、构建、部署和访问验证。
6. 提交并推送到 Gitea。
## 兼容性与回滚方案
- 修改只影响显示与导出 mask 生成,不影响项目状态结构和上传数据。
- 如发现膨胀过强,可通过减小兜底半径回滚到更保守的实体化。
- 回滚可直接撤销本次 commit 并重新部署。
## 预计文件变更
- 2 个核心代码文件。
- 1 个 Docker 部署说明文件。
- 3 个工程分析当次文档。
- 1 个经验记录追加。
## 提交与部署策略
- Commit message 使用 `2026-05-24-18-59-49 修正薄壳构件实体化映射`
- 通过 `npm run lint``npm run build` 后重启 `tmux` 会话 `revoxelseg-dicom`
- 验证本机与公网入口可访问。

View File

@@ -0,0 +1,45 @@
# 测试方案-2026-05-24-18-59-49
## 测试方案文档路径
`工程分析/测试方案-2026-05-24-18-59-49.md`
## 静态检查
- 执行 `cd WebSite && npm run lint`
- 搜索新增实体化函数调用,确认前端显示与后端导出均覆盖。
## 构建检查
- 执行 `cd WebSite && npm run build`
- 确认生产构建成功。
## 关键业务场景验证
- 当前项目在逆向分割映射视图中显示 rib、skin、肝脏等构件时薄构件不再只剩散点或单像素线段。
- 切换构件可见性后,加载进度条仍正常显示。
- 肝脏等大实体构件仍保持实体填充,不被过度扩张。
## 医学影像数据相关边界验证
- 单构件内多个断开小区域保持独立处理,不跨构件混色。
- thin shell/骨骼结构允许视觉上略厚于真实切面,但不能跨越远距离碎片形成大面积桥接。
- 导出 NIfTI 的实体化策略与前端一致。
## 部署验证
- 重启 `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 备份验证
- 仅暂存本次相关代码和工程分析文档。
- 提交 message 包含 `2026-05-24-18-59-49`
- 推送到 Gitea `main` 并确认本地分支与远端同步。
## 风险与回归关注点
- rib/skin 这类结构在医学真实切面中可能本来很薄,本次是为了满足用户的实体化显示需求。
- 如果后续接入真实体素化算法,应把当前膨胀兜底作为可配置的显示/演示层,而不是临床级分割后处理。

View File

@@ -1747,3 +1747,21 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
后续只要某个 UI 操作会触发异步加载大 STL、DICOM 或导出预计算,都应提供明确加载状态、进度和空状态,避免用户把正常等待理解为显示故障。需要阻止滚轮默认滚动时,应使用原生非被动 wheel 监听并在卸载时清理;触摸按钮优先使用 pointer 事件和 `touch-action` 控制,不要在 passive-prone 的 touch 合成事件中调用 `preventDefault` 后续只要某个 UI 操作会触发异步加载大 STL、DICOM 或导出预计算,都应提供明确加载状态、进度和空状态,避免用户把正常等待理解为显示故障。需要阻止滚轮默认滚动时,应使用原生非被动 wheel 监听并在卸载时清理;触摸按钮优先使用 pointer 事件和 `touch-action` 控制,不要在 passive-prone 的 touch 合成事件中调用 `preventDefault`
## 2026-05-24-18-59-49 薄壳和细长构件需要局部实体化兜底
A. 具体问题
用户反馈当前项目中 `rib` 在三维融合视图里仍像散点,逆向分割映射视图右侧仍有一条一条的线;希望 `skin``rib` 等所有构件在映射视图中尽量显示为实心。
B. 产生问题原因
当前项目 `rib.stl` 有 524364 个三角面、`skin.stl` 有 2720412 个三角面。此前实体模式预览上限为 20 万,二维映射高精度上限为 50 万;由于预览抽样使用 `ceil(triangleCount / limit)` 计算步长,`rib` 在 50 万上限下仍会变成每隔一个三角面取样,细长结构更容易断成点线。另一个原因是二维填充主要依赖扫描线交点配对,近水平切线会被跳过;当 thin shell 或肋骨切面不能形成完整闭合轮廓时,就只剩线段描边。
C. 解决问题方案
将实体模式和逆向分割映射预览上限提高到 80 万,服务端 STL preview 上限同步提高到 80 万,使 `rib` 能全量进入实体预览,`skin` 也能获得更高密度抽样。三维实体模式材质改为不透明并写入深度,减少透明材质排序造成的散点观感。前端 overlay 和服务端 NIfTI 导出在扫描线填充之外,对每个连通组的原始切面线段绘制局部 capsule 厚度,再做小缝隙闭合和内部孔洞填充,保证薄壳、细长构件也有实体化兜底。
D. 后续如何避免问题
后续遇到薄壳、肋骨、皮肤、血管等结构显示成点线时要同时检查三件事STL preview 是否因上限导致抽样缺面、二维切面算法是否跳过水平/切线段、透明材质是否造成三维自排序噪声。实体化显示和导出必须同步处理,不能只改 UI但局部厚度兜底属于演示/显示后处理,接入真实体素化算法后应作为可配置显示选项,而不是替代真实分割算法。

View File

@@ -0,0 +1,44 @@
# 需求分析-2026-05-24-18-59-49
## 开始时间
2026-05-24-18-59-49
## 原始需求摘要
用户反馈 `rib` 在三维融合视图中仍像散点,逆向分割映射视图右侧仍有一条一条的显示效果;希望包括 `skin``rib` 在内的所有构件在“逆向分割映射视图”中都尽可能显示为实心区域。用户认为此前若已修改过,应重新处理当前项目。
## 业务目标
- 让薄壳、细长、稀疏切面构件在二维逆向分割映射中有实体填充兜底。
- 减少 rib/skin 等构件在切面上仅显示散点、短线或断续线段的视觉问题。
- 保持已有肝脏等大实体构件的填充效果,不引入跨构件或跨断开区域的大面积误连。
## 输入与输出
- 输入:当前项目 STL 构件、DICOM 切片、模型位姿、构件可见状态。
- 输出:逆向分割映射视图中,所有可见构件的切面尽量以填充实体呈现;对 thin shell/骨骼类断续切面增加厚度和闭合兜底。
## 影响范围
- `WebSite/src/components/ReverseWorkspace.tsx`:前端二维 overlay 切面填充与兜底显示。
- `WebSite/server.ts`NIfTI 导出算法若与前端共享相同实体化策略,需要同步修正。
- `Docker部署/README.md`:同步说明实体化兜底能力。
- `工程分析/经验记录.md`:沉淀薄壳/细长结构实体化经验。
## 关键约束
- 不能修改原始 DICOM/STL 文件。
- 不能把不同构件混成一个标签;填充只能在单构件内部处理。
- 当前工程仍是演示闭环,修改应明确为视觉和演示导出层面的实体化兜底,不声称真实临床算法。
## 风险点
- rib/skin 这类结构天然可能是薄壳或细长带状结构,过度膨胀会让其比真实切面更厚。
- 若直接全局连接所有点,可能重新产生之前的长桥和错误相连。
- 导出算法与前端显示不一致,会让用户看到实体但下载后仍是线。
## 待确认问题或默认假设
- 默认按用户要求将所有构件在二维映射中做“实体化显示兜底”,优先视觉和导出演示一致性。
- 对 rib/skin 这类薄结构采用按连通组局部膨胀和闭合,不跨构件、不跨明显远距离碎片。