diff --git a/WebSite/server.ts b/WebSite/server.ts index 0b0e017..7898a50 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -718,6 +718,58 @@ function intersectExportTriangleWithPlane(a: Point3DRecord, b: Point3DRecord, c: return maxDistance > 1e-8 ? segment : null; } +function readBinaryStlTriangleCount(buffer: Buffer, fileName: string) { + if (buffer.length < 84) { + throw new Error(`STL 文件内容为空或不完整:${fileName}`); + } + + const triangleCount = buffer.readUInt32LE(80); + const expectedLength = 84 + triangleCount * 50; + if (triangleCount <= 0 || expectedLength > buffer.length + 1024) { + throw new Error(`当前仅支持二进制 STL:${fileName}`); + } + + return triangleCount; +} + +function forEachBinaryStlTriangle( + filePath: string, + fileName: string, + callback: ( + ax: number, + ay: number, + az: number, + bx: number, + by: number, + bz: number, + cx: number, + cy: number, + cz: number, + ) => void, +) { + const buffer = fs.readFileSync(filePath); + const triangleCount = readBinaryStlTriangleCount(buffer, fileName); + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const offset = 84 + triangleIndex * 50; + if (offset + 50 > buffer.length) { + break; + } + + callback( + buffer.readFloatLE(offset + 12), + buffer.readFloatLE(offset + 16), + buffer.readFloatLE(offset + 20), + buffer.readFloatLE(offset + 24), + buffer.readFloatLE(offset + 28), + buffer.readFloatLE(offset + 32), + buffer.readFloatLE(offset + 36), + buffer.readFloatLE(offset + 40), + buffer.readFloatLE(offset + 44), + ); + } +} + function addExportSegmentToRows(rows: number[][], width: number, height: number, segment: PlaneSegmentRecord) { const deltaY = segment.b.y - segment.a.y; if (Math.abs(deltaY) < 0.01) { @@ -742,8 +794,61 @@ function addExportSegmentToRows(rows: number[][], width: number, height: number, } } +function fillExportInternalHoles(mask: Uint8Array, width: number, height: number) { + const outside = new Uint8Array(width * height); + const stack: number[] = []; + const pushIfEmpty = (x: number, y: number) => { + if (x < 0 || x >= width || y < 0 || y >= height) { + return; + } + + const index = y * width + x; + if (outside[index] || mask[index]) { + return; + } + + outside[index] = 1; + stack.push(index); + }; + + for (let x = 0; x < width; x += 1) { + pushIfEmpty(x, 0); + pushIfEmpty(x, height - 1); + } + for (let y = 0; y < height; y += 1) { + pushIfEmpty(0, y); + pushIfEmpty(width - 1, y); + } + + while (stack.length) { + const index = stack.pop(); + if (index === undefined) { + continue; + } + + const x = index % width; + const y = Math.floor(index / width); + pushIfEmpty(x + 1, y); + pushIfEmpty(x - 1, y); + pushIfEmpty(x, y + 1); + pushIfEmpty(x, y - 1); + } + + let patchedPixels = 0; + for (let index = 0; index < mask.length; index += 1) { + if (!outside[index] && !mask[index]) { + mask[index] = 1; + patchedPixels += 1; + } + } + + return patchedPixels; +} + function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) { - const sliceOffset = slice * width * height; + const mask = new Uint8Array(width * height); + let filledPixels = 0; + rows.forEach((intersections, row) => { if (intersections.length < 2) { return; @@ -768,10 +873,28 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb const startX = clampNumber(Math.ceil(rawStartX), 0, width - 1); const endX = clampNumber(Math.floor(rawEndX), 0, width - 1); for (let x = startX; x <= endX; x += 1) { - data[sliceOffset + row * width + x] = label; + const index = row * width + x; + if (!mask[index]) { + mask[index] = 1; + filledPixels += 1; + } } } }); + + if (filledPixels === 0) { + return 0; + } + + filledPixels += fillExportInternalHoles(mask, width, height); + const sliceOffset = slice * width * height; + for (let index = 0; index < mask.length; index += 1) { + if (mask[index]) { + data[sliceOffset + index] = label; + } + } + + return filledPixels; } function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord { @@ -836,25 +959,36 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p return rows; }; - for (let vertexIndex = 0; vertexIndex + 8 < payload.vertices.length; vertexIndex += 9) { + const filePath = path.join(modelDir, fileName); + forEachBinaryStlTriangle(filePath, fileName, ( + ax, + ay, + az, + bx, + by, + bz, + cx, + cy, + cz, + ) => { const a = transformPointForExportPose( - payload.vertices[vertexIndex], - payload.vertices[vertexIndex + 1], - payload.vertices[vertexIndex + 2], + ax, + ay, + az, metrics, pose, ); const b = transformPointForExportPose( - payload.vertices[vertexIndex + 3], - payload.vertices[vertexIndex + 4], - payload.vertices[vertexIndex + 5], + bx, + by, + bz, metrics, pose, ); const c = transformPointForExportPose( - payload.vertices[vertexIndex + 6], - payload.vertices[vertexIndex + 7], - payload.vertices[vertexIndex + 8], + cx, + cy, + cz, metrics, pose, ); @@ -872,7 +1006,7 @@ function createSegmentationData(project: ProjectRecord, volume: DicomHuVolume, p b: mapPoint(segment.b), }); } - } + }); rowsBySlice.forEach((rows, slice) => { fillExportRows(data, volume.width, volume.height, slice, rows, label); diff --git a/工程分析/实现方案-2026-05-20-02-55-11.md b/工程分析/实现方案-2026-05-20-02-55-11.md new file mode 100644 index 0000000..72fc628 --- /dev/null +++ b/工程分析/实现方案-2026-05-20-02-55-11.md @@ -0,0 +1,51 @@ +# 实现方案:完整 STL 网格导出与实体填充增强 + +实现方案文档路径:`工程分析/实现方案-2026-05-20-02-55-11.md` + +## 修改目标 + +修复大面数 STL 构件导出后在 ITK-SNAP 中呈散点的问题,使分割 NIfTI 的 Label Map 基于完整二进制 STL 三角面求交,并在每层生成连续实体区域。 + +## 涉及路径 + +- `WebSite/server.ts` +- `工程分析/需求分析-2026-05-20-02-55-11.md` +- `工程分析/实现方案-2026-05-20-02-55-11.md` +- `工程分析/测试方案-2026-05-20-02-55-11.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 保留 `createStlPreview` 作为网页预览和 bounds 统计来源。 +2. 新增导出专用 STL 迭代逻辑,直接读取完整二进制 STL buffer 中的全部三角面,不再使用 `sampleLimit=200000` 的预览顶点数组作为 NIfTI 生成输入。 +3. 对每个三角面执行当前位姿变换、DICOM 物理坐标映射、Mesh-Plane Intersection 和扫描线光栅化。 +4. 将每个构件、每个 slice 的 rows 光栅化到临时 mask,再做四边 flood fill,填补闭合区域内部孔洞后写入最终 Label Map。 +5. 保持 `visible/all` 分割范围、labels JSON、导出包结构不变。 + +## 执行步骤 + +1. 阅读当前导出生成链路,确认抽样来源和填充缺口。 +2. 编写完整 STL 三角面读取辅助函数,避免长期缓存完整顶点数组。 +3. 替换 `createSegmentationData` 中遍历 `payload.vertices` 的逻辑。 +4. 增强后端 slice mask 的内部孔洞填补。 +5. 运行类型检查、构建、导出接口验证。 +6. 用脚本检查导出的 Label Map 体素分布,重点确认 `头部` label 不再只集中于少量散点。 +7. 追加经验记录、提交、推送并重新部署。 + +## 兼容性与回滚方案 + +- API 参数和前端调用不变,外部使用者无需调整。 +- 若完整 STL 导出耗时不可接受,可在后续引入后台任务或空间索引;本次先保证导出结果正确。 +- 回滚时可恢复到上一个提交,但会重新暴露大面数构件抽样导致的散点问题。 + +## 预计文件变更 + +- `WebSite/server.ts`:新增完整 STL 迭代与 slice mask 填充逻辑。 +- `工程分析/*-2026-05-20-02-55-11.md`:记录本次工作流。 +- `工程分析/经验记录.md`:追加 A/B/C/D 经验。 + +## 提交与部署策略 + +- 只暂存本次相关代码和工程分析文档。 +- commit message 包含 `2026-05-20-02-55-11` 和简要描述。 +- 推送到 Gitea 后使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。 diff --git a/工程分析/测试方案-2026-05-20-02-55-11.md b/工程分析/测试方案-2026-05-20-02-55-11.md new file mode 100644 index 0000000..287294a --- /dev/null +++ b/工程分析/测试方案-2026-05-20-02-55-11.md @@ -0,0 +1,65 @@ +# 测试方案:头部 STL 实体导出验证 + +测试方案文档路径:`工程分析/测试方案-2026-05-20-02-55-11.md` + +## 静态检查 + +- 运行 `npm run lint`,确认 TypeScript 类型检查通过。 +- 运行 `git diff --check`,确认无空白错误。 + +结果:已通过。 + +## 构建检查 + +- 在 `WebSite/` 执行 `npm run build`,确认生产构建通过。 + +结果:已通过。Vite 仍提示单个 bundle 超过 500 kB,此为既有体积提示,不影响本次导出修复。 + +## 关键业务场景验证 + +- 启动临时服务或复用部署服务。 +- 请求分割导出包: + - `targets=segmentation,pose` + - `format=nii.gz` + - `segmentationScope=visible` +- 解包并检查包含: + - `*-segmentation-label.nii.gz` + - `*-segmentation-labels.json` + - `*-pose-data.json` + +结果:已通过。临时服务 `127.0.0.1:4100` 下: + +- `segmentationScope=visible`:HTTP 200,约 431802 bytes,用时约 6.56 s。 +- `segmentationScope=all`:HTTP 200,约 667690 bytes,用时约 8.51 s。 +- 压缩包内包含分割 NIfTI、labels JSON 与 pose JSON。 + +## 医学影像数据相关边界验证 + +- 读取导出的 NIfTI header,确认维度仍为 DICOM 同维度。 +- 统计 label 体素分布,重点检查 `头部` 对应 label 的体素数量和 slice 分布不再表现为极少量散点。 +- 检查导出包内 labels JSON 中 `头部.stl` 的 label 对应关系。 + +结果:已通过。 + +- NIfTI 维度:`512 x 512 x 300`,`vox_offset=352`。 +- `visible` 范围下 `头部.stl` 对应 label 7,体素数 `11742597`,覆盖 slice `13-287`,共 `275` 层,最大单层 `83903` 体素。 +- `all` 范围下 label 7 体素数 `9845171`,label 8 `头颅.stl` 体素数 `1930290`,二者均覆盖 `275` 层。 +- labels JSON 中 `头部.stl` 的 label/partId 为 `7`。 + +## 部署验证 + +- 重新部署后验证: + - `http://127.0.0.1:4000/api/health` + - `http://127.0.0.1:4000/` +- 对部署后的导出包端点再做一次小样本验证。 + +## Git/Gitea 备份验证 + +- 提交信息包含 `2026-05-20-02-55-11` 和本次修复摘要。 +- 推送到 Gitea 成功。 + +## 风险与回归关注点 + +- 完整 STL 导出比预览抽样更耗时,需要观察请求耗时。 +- 多构件重叠时仍按现有顺序写入 label,后写构件会覆盖先写构件。 +- 旧的未暂存历史删除状态不应混入提交。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index ab8fcf9..c32e237 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1117,3 +1117,21 @@ C. 解决问题方案 D. 后续如何避免问题 任何分割影像导出都应同时考虑语义侧车文件,并保证侧车元数据与实际 mask 标签来自同一批样式和筛选条件。多文件导出优先做成一个后端归档包,避免浏览器多下载顺序、丢文件或元数据错配。 + +## 2026-05-20-02-55-11 大面数 STL 导出不能复用预览抽样网格 + +A. 具体问题 + +用户在 ITK-SNAP 中查看导出的分割 NIfTI 时,`头部` 类别仍呈散点或点云状,而原始 `头部.stl` 是完整实体表面。 + +B. 产生问题原因 + +后端 NIfTI 分割生成复用了 `createStlPreview(file, 200000)` 的预览顶点数组。`头部.stl` 约有 257 万个三角面,但导出时最多只保留 20 万个抽样面;闭合轮廓被抽稀后,Mesh-Plane Intersection 生成的扫描线交点不连续,实体填充会退化成稀疏点/线。 + +C. 解决问题方案 + +保留预览抽样数据仅用于 bounds 和网页显示;NIfTI 导出路径改为逐三角读取完整二进制 STL buffer,对全部网格面执行位姿变换、平面求交和扫描线光栅化。同时后端每个构件、每个 slice 先写入临时二值 mask,再通过四边 flood fill 标记外部区域,将内部未连通孔洞补齐后写入最终 Label Map。 + +D. 后续如何避免问题 + +预览数据、抽样数据和医学导出数据必须明确分层。凡是 NIfTI、Mask、Label Map、体素化这类可用于医学工具复核的导出功能,都不得复用用于前端性能优化的抽样网格;验证时必须检查大面数构件的 label 体素数量、slice 覆盖范围和侧视/冠状重建是否连续。 diff --git a/工程分析/需求分析-2026-05-20-02-55-11.md b/工程分析/需求分析-2026-05-20-02-55-11.md new file mode 100644 index 0000000..30df0c3 --- /dev/null +++ b/工程分析/需求分析-2026-05-20-02-55-11.md @@ -0,0 +1,51 @@ +# 需求分析:修复头部 STL 导出后仍呈散点 + +开始时间:2026-05-20-02-55-11 + +## 原始需求摘要 + +用户反馈在 ITK-SNAP 中打开导出的 `head-ct-demo-segmentation-label.nii` 后,“头部”类别仍呈散点形式;用户指出 STL 本体不是散点,头部 STL 在三维查看器中是完整实体表面,因此导出的分割影像也不应呈点云。 + +## 业务目标 + +- 分割影像导出必须基于真实 STL 网格生成实体 Label Map。 +- 大面数构件(尤其 `头部.stl`、`头颅.stl`)不能因预览抽样导致闭合轮廓断裂。 +- ITK-SNAP 中轴向、冠状、矢状视图应显示连续实体分割,而不是稀疏点云。 + +## 输入与输出 + +- 输入: + - 默认项目 `head-ct-demo`。 + - DICOM 原始序列。 + - `Head_CT_ReConstruct/头部.stl` 等二进制 STL。 + - 当前模型位姿、构件显隐与导出范围。 +- 输出: + - 修复后的分割 NIfTI / NIfTI.GZ / 导出包。 + - 工程分析文档与经验记录。 + +## 影响范围 + +- 后端 `WebSite/server.ts` 中 STL 到 NIfTI Label Map 的生成逻辑。 +- 导出接口: + - `/api/projects/:projectId/export-mask` + - `/api/projects/:projectId/export-nifti` + - `/api/projects/:projectId/export-bundle` +- 前端下载入口原则上不需要改变,除非发现参数传递问题。 + +## 关键约束 + +- 保持 DICOM 维度、spacing、slice 数量与当前导出逻辑一致。 +- 保持 labels JSON、位姿 JSON、压缩包结构兼容。 +- 不将大型 DICOM/STL 数据或运行态导出文件纳入 Git 提交。 +- 最终仍需重新部署并验证服务。 + +## 风险点 + +- 完整 STL 面数较大,不能把所有顶点长期存为大型 JS 数组造成内存膨胀。 +- 需要避免继续复用预览抽样数据做医学导出。 +- 若 STL 存在非闭合切口,仅靠扫描线偶奇填充可能仍出现孔洞,需要补充连通域填补。 + +## 默认假设 + +- 用户希望本次直接修复导出的 NIfTI 分割质量,而不是只解释原因。 +- 头部散点的主要原因是后端导出使用了预览抽样网格,导致实体轮廓无法闭合。