From e9f0823281288b91388baeebcfea7dff5619d7e0 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 24 May 2026 15:47:59 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-24-15-37-16=20=E4=BF=AE=E6=AD=A3mask?= =?UTF-8?q?=E7=BA=BF=E6=9D=A1=E6=A1=A5=E6=8E=A5=E4=B8=8EDICOM=E5=A4=8D?= =?UTF-8?q?=E4=BD=8D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 128 ++++++++- WebSite/src/components/ProjectLibrary.tsx | 26 +- WebSite/src/components/ReverseWorkspace.tsx | 276 +++++++++++++++----- 工程分析/实现方案-2026-05-24-15-37-16.md | 58 ++++ 工程分析/测试方案-2026-05-24-15-37-16.md | 47 ++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-24-15-37-16.md | 48 ++++ 7 files changed, 515 insertions(+), 86 deletions(-) create mode 100644 工程分析/实现方案-2026-05-24-15-37-16.md create mode 100644 工程分析/测试方案-2026-05-24-15-37-16.md create mode 100644 工程分析/需求分析-2026-05-24-15-37-16.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 57c0655..91b4148 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -1045,6 +1045,68 @@ function addExportSegmentToRows(rows: number[][], width: number, height: number, } } +function groupExportSegmentsByConnectivity(segments: PlaneSegmentRecord[], tolerance = 1.35) { + if (segments.length <= 1) { + return segments.length ? [segments] : []; + } + + const parents = segments.map((_, index) => index); + const find = (index: number): number => { + if (parents[index] !== index) { + parents[index] = find(parents[index]); + } + return parents[index]; + }; + const union = (left: number, right: number) => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) { + parents[rightRoot] = leftRoot; + } + }; + const buckets = new Map>(); + const cellSize = Math.max(tolerance, 0.1); + const toleranceSquared = tolerance * tolerance; + const cellKey = (x: number, y: number) => `${x},${y}`; + + segments.forEach((segment, index) => { + [segment.a, segment.b].forEach((point) => { + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { + return; + } + + const cellX = Math.floor(point.x / cellSize); + const cellY = Math.floor(point.y / cellSize); + for (let dx = -1; dx <= 1; dx += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + const candidates = buckets.get(cellKey(cellX + dx, cellY + dy)); + candidates?.forEach((candidate) => { + const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2; + if (distanceSquared <= toleranceSquared) { + union(index, candidate.index); + } + }); + } + } + + const key = cellKey(cellX, cellY); + const bucket = buckets.get(key) ?? []; + bucket.push({ x: point.x, y: point.y, index }); + buckets.set(key, bucket); + }); + }); + + const groups = new Map(); + segments.forEach((segment, index) => { + const root = find(index); + const group = groups.get(root) ?? []; + group.push(segment); + groups.set(root, group); + }); + + return [...groups.values()].sort((left, right) => right.length - left.length); +} + function fillExportInternalHoles(mask: Uint8Array, width: number, height: number) { const outside = new Uint8Array(width * height); const stack: number[] = []; @@ -1096,6 +1158,53 @@ function fillExportInternalHoles(mask: Uint8Array, width: number, height: number return patchedPixels; } +function closeExportMaskGaps(mask: Uint8Array, width: number, height: number, maxGap = 2) { + const toFill = new Set(); + const hasPixel = (x: number, y: number) => mask[y * width + x] > 0; + const mark = (x: number, y: number) => { + if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) { + toFill.add(y * width + x); + } + }; + + for (let y = 0; y < height; y += 1) { + let lastFilled = -1; + for (let x = 0; x < width; x += 1) { + if (!hasPixel(x, y)) { + continue; + } + const gap = x - lastFilled - 1; + if (lastFilled >= 0 && gap > 0 && gap <= maxGap) { + for (let fillX = lastFilled + 1; fillX < x; fillX += 1) { + mark(fillX, y); + } + } + lastFilled = x; + } + } + + for (let x = 0; x < width; x += 1) { + let lastFilled = -1; + for (let y = 0; y < height; y += 1) { + if (!hasPixel(x, y)) { + continue; + } + const gap = y - lastFilled - 1; + if (lastFilled >= 0 && gap > 0 && gap <= maxGap) { + for (let fillY = lastFilled + 1; fillY < y; fillY += 1) { + mark(x, fillY); + } + } + lastFilled = y; + } + } + + toFill.forEach((index) => { + mask[index] = 1; + }); + return toFill.size; +} + function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) { const mask = new Uint8Array(width * height); let filledPixels = 0; @@ -1137,6 +1246,7 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb return 0; } + filledPixels += closeExportMaskGaps(mask, width, height); filledPixels += fillExportInternalHoles(mask, width, height); const sliceOffset = slice * width * height; for (let index = 0; index < mask.length; index += 1) { @@ -1269,14 +1379,13 @@ function createSegmentationData( } const label = clampNumber(Math.round(style.partId || index + 1), 1, 255); - const slicesByIndex = new Map(); + const slicesByIndex = new Map(); const entryForSlice = (slice: number) => { const existing = slicesByIndex.get(slice); if (existing) { return existing; } const entry = { - rows: Array.from({ length: volume.height }, () => [] as number[]), segments: [] as PlaneSegmentRecord[], }; slicesByIndex.set(slice, entry); @@ -1330,16 +1439,19 @@ function createSegmentationData( b: mapPoint(segment.b), }; const entry = entryForSlice(slice); - addExportSegmentToRows(entry.rows, volume.width, volume.height, mappedSegment); entry.segments.push(mappedSegment); } }); - slicesByIndex.forEach(({ rows, segments }, slice) => { - const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label); - if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) { - fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, segments, label); - } + slicesByIndex.forEach(({ segments }, slice) => { + groupExportSegmentsByConnectivity(segments).forEach((group) => { + const rows = Array.from({ length: volume.height }, () => [] as number[]); + group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment)); + const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label); + if (filledPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) { + fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label); + } + }); }); }); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 32b0fe1..c4167aa 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -327,7 +327,7 @@ function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) { return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex))); } -function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) { +function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) { const canvasRef = useRef(null); const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 }); const [isPanning, setIsPanning] = useState(false); @@ -348,9 +348,10 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n drawDicomPreviewToCanvas(canvas, preview, rotation); }, [preview, rotation]); - const resetViewport = () => { + useEffect(() => { setViewport({ scale: 1, offsetX: 0, offsetY: 0 }); - }; + }, [resetSignal]); + const handleWheel = (event: React.WheelEvent) => { event.preventDefault(); const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; @@ -418,15 +419,6 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n className="max-h-full max-w-full select-none object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25" /> - ); } @@ -838,6 +830,7 @@ export default function ProjectLibrary({ const [plane, setPlane] = useState('axial'); const [displayMode, setDisplayMode] = useState('default'); const [rotation, setRotation] = useState(0); + const [dicomViewportResetSignal, setDicomViewportResetSignal] = useState(0); const [isSliceChanging, setIsSliceChanging] = useState(false); const [solidityLevel, setSolidityLevel] = useState('standard'); const [modelPose, setModelPose] = useState(defaultModelPose); @@ -1814,6 +1807,13 @@ export default function ProjectLibrary({ > 右转 +

PATIENT ID: {selectedProject.id}_XYZ

@@ -1822,7 +1822,7 @@ export default function ProjectLibrary({
{dicomPreview ? ( - + ) : (

{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'} diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 5599f98..d43b5b1 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -1666,6 +1666,155 @@ function fillInternalMaskHoles( return patchedPixels; } +function addSegmentIntersectionsToRows(rows: number[][], width: number, height: number, segment: PlaneSegment) { + const { a, b } = segment; + if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) { + return; + } + + const deltaY = b.y - a.y; + if (Math.abs(deltaY) < 0.01) { + return; + } + + const minY = Math.max(0, Math.floor(Math.min(a.y, b.y))); + const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y))); + for (let row = minY; row <= maxY; row += 1) { + const sampleY = row + 0.5; + const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y); + if (!crosses) { + continue; + } + + const t = (sampleY - a.y) / deltaY; + const x = a.x + (b.x - a.x) * t; + if (Number.isFinite(x)) { + rows[row].push(x); + } + } +} + +function groupPlaneSegmentsByConnectivity(segments: PlaneSegment[], tolerance = 1.35) { + if (segments.length <= 1) { + return segments.length ? [segments] : []; + } + + const parents = segments.map((_, index) => index); + const find = (index: number): number => { + if (parents[index] !== index) { + parents[index] = find(parents[index]); + } + return parents[index]; + }; + const union = (left: number, right: number) => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) { + parents[rightRoot] = leftRoot; + } + }; + const buckets = new Map>(); + const cellSize = Math.max(tolerance, 0.1); + const toleranceSquared = tolerance * tolerance; + const cellKey = (x: number, y: number) => `${x},${y}`; + + segments.forEach((segment, index) => { + [segment.a, segment.b].forEach((point) => { + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { + return; + } + + const cellX = Math.floor(point.x / cellSize); + const cellY = Math.floor(point.y / cellSize); + for (let dx = -1; dx <= 1; dx += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + const candidates = buckets.get(cellKey(cellX + dx, cellY + dy)); + candidates?.forEach((candidate) => { + const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2; + if (distanceSquared <= toleranceSquared) { + union(index, candidate.index); + } + }); + } + } + + const key = cellKey(cellX, cellY); + const bucket = buckets.get(key) ?? []; + bucket.push({ x: point.x, y: point.y, index }); + buckets.set(key, bucket); + }); + }); + + const groups = new Map(); + segments.forEach((segment, index) => { + const root = find(index); + const group = groups.get(root) ?? []; + group.push(segment); + groups.set(root, group); + }); + + return [...groups.values()].sort((left, right) => right.length - left.length); +} + +function closeSmallMaskGaps( + maskData: ImageData, + width: number, + height: number, + rgb: { r: number; g: number; b: number }, + alpha: number, + maxGap = 2, +) { + const toFill = new Set(); + const hasPixel = (x: number, y: number) => maskData.data[(y * width + x) * 4 + 3] > 0; + const mark = (x: number, y: number) => { + if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) { + toFill.add(y * width + x); + } + }; + + for (let y = 0; y < height; y += 1) { + let lastFilled = -1; + for (let x = 0; x < width; x += 1) { + if (!hasPixel(x, y)) { + continue; + } + const gap = x - lastFilled - 1; + if (lastFilled >= 0 && gap > 0 && gap <= maxGap) { + for (let fillX = lastFilled + 1; fillX < x; fillX += 1) { + mark(fillX, y); + } + } + lastFilled = x; + } + } + + for (let x = 0; x < width; x += 1) { + let lastFilled = -1; + for (let y = 0; y < height; y += 1) { + if (!hasPixel(x, y)) { + continue; + } + const gap = y - lastFilled - 1; + if (lastFilled >= 0 && gap > 0 && gap <= maxGap) { + for (let fillY = lastFilled + 1; fillY < y; fillY += 1) { + mark(x, fillY); + } + } + lastFilled = y; + } + } + + toFill.forEach((index) => { + const offset = index * 4; + maskData.data[offset] = rgb.r; + maskData.data[offset + 1] = rgb.g; + maskData.data[offset + 2] = rgb.b; + maskData.data[offset + 3] = alpha; + }); + + return toFill.size; +} + function drawFallbackClosedRegion( context: CanvasRenderingContext2D, width: number, @@ -1688,7 +1837,17 @@ function drawFallbackClosedRegion( return 0; } - const sorted = [...points].sort((left, right) => ( + const uniquePoints: Point2D[] = []; + points.forEach((point) => { + if (!uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-6)) { + uniquePoints.push(point); + } + }); + if (uniquePoints.length < 3) { + return 0; + } + + const sorted = [...uniquePoints].sort((left, right) => ( Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y )); const cross = (origin: Point2D, a: Point2D, b: Point2D) => ( @@ -1709,7 +1868,7 @@ function drawFallbackClosedRegion( upper.push(point); }); const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)]; - const ordered = hull.length >= 3 ? hull : points; + const ordered = hull.length >= 3 ? hull : uniquePoints; context.save(); context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62; @@ -1741,35 +1900,6 @@ function fillSegmentsAsSolidMask( return 0; } - const rows: number[][] = Array.from({ length: height }, () => []); - segments.forEach((segment) => { - const { a, b } = segment; - if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) { - return; - } - - const deltaY = b.y - a.y; - if (Math.abs(deltaY) < 0.01) { - return; - } - - const minY = Math.max(0, Math.floor(Math.min(a.y, b.y))); - const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y))); - for (let row = minY; row <= maxY; row += 1) { - const sampleY = row + 0.5; - const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y); - if (!crosses) { - continue; - } - - const t = (sampleY - a.y) / deltaY; - const x = a.x + (b.x - a.x) * t; - if (Number.isFinite(x)) { - rows[row].push(x); - } - } - }); - const rgb = parseHexColor(color); const alpha = Math.round(clamp(opacity, 0.1, 1) * 190); const maskCanvas = document.createElement('canvas'); @@ -1781,51 +1911,67 @@ function fillSegmentsAsSolidMask( } const maskData = maskContext.createImageData(width, height); let filledPixels = 0; + const groups = groupPlaneSegmentsByConnectivity(segments); + const fallbackGroups: PlaneSegment[][] = []; - rows.forEach((intersections, row) => { - if (intersections.length < 2) { - return; - } + groups.forEach((group) => { + const rows: number[][] = Array.from({ length: height }, () => []); + group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment)); + let groupPixels = 0; - intersections.sort((left, right) => left - right); - const cleaned: number[] = []; - intersections.forEach((x) => { - const previous = cleaned[cleaned.length - 1]; - if (previous === undefined || Math.abs(previous - x) > 0.35) { - cleaned.push(x); + rows.forEach((intersections, row) => { + if (intersections.length < 2) { + return; + } + + intersections.sort((left, right) => left - right); + const cleaned: number[] = []; + intersections.forEach((x) => { + const previous = cleaned[cleaned.length - 1]; + if (previous === undefined || Math.abs(previous - x) > 0.35) { + cleaned.push(x); + } + }); + + for (let index = 0; index + 1 < cleaned.length; index += 2) { + const rawStartX = cleaned[index]; + const rawEndX = cleaned[index + 1]; + if (rawEndX < 0 || rawStartX > width - 1) { + continue; + } + + const startX = clamp(Math.ceil(rawStartX), 0, width - 1); + const endX = clamp(Math.floor(rawEndX), 0, width - 1); + if (endX < startX) { + continue; + } + + for (let x = startX; x <= endX; x += 1) { + const offset = (row * width + x) * 4; + if (maskData.data[offset + 3] === 0) { + maskData.data[offset] = rgb.r; + maskData.data[offset + 1] = rgb.g; + maskData.data[offset + 2] = rgb.b; + maskData.data[offset + 3] = alpha; + groupPixels += 1; + } + } } }); - for (let index = 0; index + 1 < cleaned.length; index += 2) { - const rawStartX = cleaned[index]; - const rawEndX = cleaned[index + 1]; - if (rawEndX < 0 || rawStartX > width - 1) { - continue; - } - - const startX = clamp(Math.ceil(rawStartX), 0, width - 1); - const endX = clamp(Math.floor(rawEndX), 0, width - 1); - if (endX < startX) { - continue; - } - - for (let x = startX; x <= endX; x += 1) { - const offset = (row * width + x) * 4; - maskData.data[offset] = rgb.r; - maskData.data[offset + 1] = rgb.g; - maskData.data[offset + 2] = rgb.b; - maskData.data[offset + 3] = alpha; - filledPixels += 1; - } + filledPixels += groupPixels; + if (groupPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) { + fallbackGroups.push(group); } }); + filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha); filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); maskContext.putImageData(maskData, 0, 0); context.drawImage(maskCanvas, 0, 0); - if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) { - filledPixels += drawFallbackClosedRegion(context, width, height, segments, color, opacity); - } + fallbackGroups.forEach((group) => { + filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity); + }); if (filledPixels === 0) { context.save(); diff --git a/工程分析/实现方案-2026-05-24-15-37-16.md b/工程分析/实现方案-2026-05-24-15-37-16.md new file mode 100644 index 0000000..6fdf506 --- /dev/null +++ b/工程分析/实现方案-2026-05-24-15-37-16.md @@ -0,0 +1,58 @@ +# 实现方案-2026-05-24-15-37-16 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-24-15-37-16.md` + +## 修改目标 + +- 将项目库 DICOM 影像位置重置按钮移动/补充到左转、右转按钮组旁。 +- 对前端逆向分割映射的切面线段进行连通分组,避免不同组件或孤立碎片被同一行扫描线错误配对。 +- 对服务端导出 Label Map 使用同样的连通分组策略,减少不规则相连线。 +- 增加小缝隙闭合处理,缓解实体区域内部横向黑线。 +- 保留对开口/非流形模型的保守说明。 + +## 涉及路径 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/server.ts` +- `工程分析/需求分析-2026-05-24-15-37-16.md` +- `工程分析/实现方案-2026-05-24-15-37-16.md` +- `工程分析/测试方案-2026-05-24-15-37-16.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 项目库 DICOM 画布由父组件下发 reset token,让外部按钮触发内部 viewport 复位。 +2. 前端 overlay 按切面线段端点近邻关系分组,每个连通组独立扫描线填充。 +3. 对每个连通组的 mask 做小 gap closing,再做内部孔洞填充。 +4. 服务端 NIfTI 生成路径复用同样的分组、逐组填充、gap closing 和兜底逻辑。 +5. 对孤立或开口线段组件减少跨组件配对,避免导出长线桥接。 + +## 执行步骤 + +1. 创建当次三件套。 +2. 再次确认已读 `工程分析/经验记录.md`。 +3. 修改项目库 DICOM 控件。 +4. 修改前端 overlay 填充算法。 +5. 修改服务端导出算法。 +6. 执行 `npm run lint` 和 `npm run build`。 +7. 重新部署并验证本机、公网、导出包。 +8. 追加经验记录,提交并推送。 + +## 兼容性与回滚方案 + +- UI reset token 只影响 DICOM 预览视窗状态,不改变 DICOM 数据。 +- 连通分组只改变轮廓填充方式,不改变项目状态结构。 +- 若新填充策略过度保守,可回滚到上一提交 `f279770` 的扫描线逻辑。 + +## 预计文件变更 + +- 前端组件和后端导出逻辑。 +- 本次工程分析文档和经验记录。 + +## 提交与部署策略 + +- commit message:`2026-05-24-15-37-16 修正mask线条桥接与DICOM复位入口` +- 部署沿用 `tmux` 会话 `revoxelseg-dicom`,生产模式端口 `4000`。 diff --git a/工程分析/测试方案-2026-05-24-15-37-16.md b/工程分析/测试方案-2026-05-24-15-37-16.md new file mode 100644 index 0000000..cbab3c2 --- /dev/null +++ b/工程分析/测试方案-2026-05-24-15-37-16.md @@ -0,0 +1,47 @@ +# 测试方案-2026-05-24-15-37-16 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-24-15-37-16.md` + +## 静态检查 + +- `git status --short --branch` +- `git diff --check` +- `rg` 检查连通分组、gap closing 和 DICOM reset token 的使用点。 + +## 构建检查 + +- `cd WebSite && npm run lint` +- `cd WebSite && npm run build` + +## 关键业务场景验证 + +- 项目库 DICOM 影像左转、右转旁可见位置重置按钮。 +- DICOM 影像滚轮缩放、拖拽平移后,外部位置重置可恢复默认视图。 +- 逆向分割映射中不同断开的线段不再被大范围水平桥接。 +- 导出的 `separate` Label Map 文件仍位于 `segmentation-parts/`。 + +## 医学影像数据相关边界验证 + +- 默认项目仍可导出分割包。 +- 中文长路径 tar 包仍可完整列出。 +- 源 STL 开口或非流形时,程序减少错误桥接,但最终说明不承诺能完全恢复真实实体。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证 `http://127.0.0.1:4000/api/health`。 +- 验证 `https://revoxel.huijutec.cn/api/health`。 +- 验证 `https://revoxel.huijutec.cn/`。 + +## Git/Gitea 备份验证 + +- commit message 包含 `2026-05-24-15-37-16`。 +- 推送后 `main` 与 `origin/main` 同步。 + +## 风险与回归关注点 + +- 连通分组阈值过大可能连接近邻独立结构,过小则闭合轮廓被拆散。 +- gap closing 不能跨大距离连接不同器官或构件。 +- DICOM reset token 不应导致每次切片变化都自动复位用户视图。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index f1013dc..3777742 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1675,3 +1675,21 @@ C. 解决问题方案 D. 后续如何避免问题 凡是新增位姿字段,必须同时检查前端类型、默认状态、保存/导入、三维渲染、二维映射、服务端归一化、导出坐标变换和项目库回放,不能只改一个界面。涉及 DICOM 层号时要明确区分“数组索引”和“对用户显示的医学层号”。导出压缩包如果包含中文或深层路径,必须用 `tar -tzf` 验证路径完整,不要依赖 100 字节 tar name 字段。分割显示的视觉修正也应同步确认导出数据路径,避免 UI 看似实体而 NIfTI 仍是轮廓。 + +## 2026-05-24-15-37-16 STL 切面填充不能跨断开轮廓全局配对 + +A. 具体问题 + +用户反馈逆向分割映射视图中仍有 mask 呈线条状,导出的 `label.nii` 里 `liver_artery` 等构件出现不规则相连长线和内部黑色线条。项目库 DICOM 影像的“位置重置”也需要放到左转、右转同一工具组,减少查找成本。 + +B. 产生问题原因 + +检查 `project-mpea0n3e-akbpn` 的 `liver_artery.stl`,其二进制 STL 约 220920 个三角面、331119 条量化边,其中边界边为 0,非流形边约 27 条,占 0.008%,说明它不是大面积开口模型。主要问题来自旧体素化逻辑:同一切片上把全部相交线段按扫描行收集后全局排序并两两配对。如果同一行存在多个断开的轮廓、孤立碎片或少量非流形小片,就会把不同连通区域的交点配成一条长桥;内部横向黑线则来自相邻扫描行之间的 1-2 像素细缝没有闭合。 + +C. 解决问题方案 + +前端 overlay 与服务端 NIfTI 导出均改为先按线段端点近邻关系做连通分组,每个连通组独立扫描线填充和闭合外轮廓兜底,避免跨断开轮廓配对。对已生成的二值 mask 增加小缝隙闭合,再执行内部孔洞填充,用于减少实体区域内部横向黑线。项目库 DICOM 画布增加外部 reset token,把“位置重置”按钮放到左转、右转旁边,直接复位缩放和平移。 + +D. 后续如何避免问题 + +后续做 STL 到 Label Map 的切面填充时,不能只按全局行交点两两配对,必须先按轮廓连通性分组。遇到长线桥接,应同时检查源 STL 的边界边/非流形边和体素化配对策略;如果 STL 是真实中心线或开口片面,只能近似显示,不能承诺恢复真实医学实体。每次修改导出算法后,至少用包含中文构件名和复杂肝脏血管的项目执行一次 `separate + all` 导出,并用 `tar -tzf` 验证文件完整。 diff --git a/工程分析/需求分析-2026-05-24-15-37-16.md b/工程分析/需求分析-2026-05-24-15-37-16.md new file mode 100644 index 0000000..9406e8b --- /dev/null +++ b/工程分析/需求分析-2026-05-24-15-37-16.md @@ -0,0 +1,48 @@ +# 需求分析-2026-05-24-15-37-16 + +## 开始时间 + +2026-05-24-15-37-16 + +## 原始需求摘要 + +- 在项目库 DICOM 影像的左转、右转按钮旁增加位置重置按钮。 +- 检查逆向工作区的逆向分割映射视图中部分 mask 仍呈线条状的原因,并继续修正。 +- 检查导出的 `label.nii` 中不规则相连、内部黑线等问题,判断是模型原因还是体素化逻辑原因,并修正可控问题。 + +## 业务目标 + +- DICOM 预览操作入口更直接,旋转与位置复位放在同一工具组。 +- 分割映射显示尽量呈现实体区域,减少横向线条、错误桥接和内部空线。 +- 导出的 NIfTI Label Map 与前端显示逻辑保持一致,避免分离构件之间被扫描线错误连接。 + +## 输入与输出 + +- 输入:当前项目库与逆向工作区的 DICOM/STL 数据、用户提供的截图、导出的 Label Map 表现。 +- 输出:前端交互修正、前后端体素化/映射填充修正、原因说明、构建部署验证、Git/Gitea 提交。 + +## 影响范围 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/server.ts` +- `工程分析/经验记录.md` +- 本次三件套文档 + +## 关键约束 + +- 必须执行 `工程分析/代码编纂工作流.md`。 +- 修改后重新构建、部署并验证 `https://revoxel.huijutec.cn/`。 +- 不能把运行态导出文件、医学数据或无关变化混入提交。 +- 对医学影像结果表述要保守,区分模型源数据问题和程序可修正的体素化问题。 + +## 风险点 + +- 如果源 STL 是开口面、非流形、中心线或带孤立碎片,无法严格恢复真实实体,只能做显示/导出层面的稳健化处理。 +- 过度填充可能把本不相连的构件误连,影响 Label Map 后处理。 +- 过滤线条伪影不能误删真实细小血管或小构件。 + +## 默认假设 + +- 用户希望优先减少错误线条桥接和内部黑线,而不是保留所有开口 STL 的线状截面。 +- 对于无法闭合的开口轮廓,可在最终说明中明确源模型质量对结果的影响。