2026-05-20-02-55-11 修复头部STL实体导出
This commit is contained in:
@@ -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);
|
||||
|
||||
51
工程分析/实现方案-2026-05-20-02-55-11.md
Normal file
51
工程分析/实现方案-2026-05-20-02-55-11.md
Normal file
@@ -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` 重新部署。
|
||||
65
工程分析/测试方案-2026-05-20-02-55-11.md
Normal file
65
工程分析/测试方案-2026-05-20-02-55-11.md
Normal file
@@ -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,后写构件会覆盖先写构件。
|
||||
- 旧的未暂存历史删除状态不应混入提交。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.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 覆盖范围和侧视/冠状重建是否连续。
|
||||
|
||||
51
工程分析/需求分析-2026-05-20-02-55-11.md
Normal file
51
工程分析/需求分析-2026-05-20-02-55-11.md
Normal file
@@ -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 分割质量,而不是只解释原因。
|
||||
- 头部散点的主要原因是后端导出使用了预览抽样网格,导致实体轮廓无法闭合。
|
||||
Reference in New Issue
Block a user