From 3b133a1d431cc8d3e4adcfdb3c72ecbf972d1203 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 8 May 2026 03:17:55 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-08-03-13-20=20=E6=8C=89DICOM=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E5=88=87=E5=88=86=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/components/ReverseWorkspace.tsx | 165 ++++++-------------- 工程分析/实现方案-2026-05-08-03-13-20.md | 54 +++++++ 工程分析/测试方案-2026-05-08-03-13-20.md | 47 ++++++ 工程分析/经验记录.md | 18 +++ 工程分析/需求分析-2026-05-08-03-13-20.md | 42 +++++ 5 files changed, 213 insertions(+), 113 deletions(-) create mode 100644 工程分析/实现方案-2026-05-08-03-13-20.md create mode 100644 工程分析/测试方案-2026-05-08-03-13-20.md create mode 100644 工程分析/需求分析-2026-05-08-03-13-20.md diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 863dad5..b71ed11 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -106,59 +106,6 @@ function createDicomTexture(frame: string, width: number, height: number) { return texture; } -function createCutMaskTexture(frame: string, width: number, height: number) { - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d'); - if (!context) { - return null; - } - - const binary = atob(frame); - const imageData = context.createImageData(width, height); - for (let index = 0; index < binary.length; index += 1) { - const value = binary.charCodeAt(index); - const offset = index * 4; - imageData.data[offset] = value; - imageData.data[offset + 1] = value; - imageData.data[offset + 2] = value; - imageData.data[offset + 3] = 245; - } - context.putImageData(imageData, 0, 0); - - const cx = width * 0.52; - const cy = height * 0.52; - const rx = width * 0.19; - const ry = height * 0.15; - context.save(); - context.beginPath(); - context.ellipse(cx, cy, rx, ry, -0.12, 0, Math.PI * 2); - context.fillStyle = 'rgba(249, 115, 22, 0.36)'; - context.fill(); - context.lineWidth = Math.max(2, Math.round(Math.min(width, height) * 0.012)); - context.strokeStyle = 'rgba(251, 191, 36, 0.95)'; - context.stroke(); - context.setLineDash([6, 4]); - context.lineWidth = Math.max(1, Math.round(Math.min(width, height) * 0.006)); - context.strokeStyle = 'rgba(255, 255, 255, 0.72)'; - context.stroke(); - context.restore(); - - context.fillStyle = 'rgba(15, 23, 42, 0.72)'; - context.fillRect(8, 8, 104, 22); - context.fillStyle = '#fed7aa'; - context.font = 'bold 12px monospace'; - context.fillText('CUT MASK', 16, 23); - - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; - texture.minFilter = THREE.LinearFilter; - texture.magFilter = THREE.LinearFilter; - texture.needsUpdate = true; - return texture; -} - function FusionThreeView({ project, volume, @@ -169,7 +116,8 @@ function FusionThreeView({ dicomOpacity, showBounds, cutEnabled, - cutSlice, + cutStart, + cutEnd, }: { project: Project; volume: DicomFusionVolume | null; @@ -180,7 +128,8 @@ function FusionThreeView({ dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number }; showBounds: boolean; cutEnabled: boolean; - cutSlice: number; + cutStart: number; + cutEnd: number; }) { const containerRef = useRef(null); const modelPoseRef = useRef(modelPose); @@ -253,23 +202,38 @@ function FusionThreeView({ edges.visible = showBounds; dicomGroup.add(edges); - const cutZ = volume.total <= 1 - ? 0 - : -dicomDepth / 2 + (dicomDepth * clamp(cutSlice, 0, volume.total - 1)) / (volume.total - 1); - const clippingPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), cutZ); - const cutPlane = new THREE.Mesh( - new THREE.PlaneGeometry(dicomWidth, dicomHeight), - new THREE.MeshBasicMaterial({ - color: '#f97316', - transparent: true, - opacity: cutEnabled ? 0.24 : 0, - side: THREE.DoubleSide, - depthWrite: false, - }), + const sliceToZ = (sliceIndex: number) => ( + volume.total <= 1 + ? 0 + : -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1) ); - cutPlane.position.set(0, 0, cutZ); - cutPlane.visible = cutEnabled; - dicomGroup.add(cutPlane); + const cutRangeStart = Math.min( + clamp(cutStart, 0, volume.total - 1), + clamp(cutEnd, 0, volume.total - 1), + ); + const cutRangeEnd = Math.max( + clamp(cutStart, 0, volume.total - 1), + clamp(cutEnd, 0, volume.total - 1), + ); + const lowerCutZ = sliceToZ(cutRangeStart); + const upperCutZ = sliceToZ(cutRangeEnd); + const lowerClippingPlane = new THREE.Plane(); + const upperClippingPlane = new THREE.Plane(); + const createCutPlaneMaterial = () => new THREE.MeshBasicMaterial({ + color: '#f97316', + transparent: true, + opacity: cutEnabled ? 0.16 : 0, + side: THREE.DoubleSide, + depthWrite: false, + }); + const lowerCutPlane = new THREE.Mesh(new THREE.PlaneGeometry(dicomWidth, dicomHeight), createCutPlaneMaterial()); + lowerCutPlane.position.set(0, 0, lowerCutZ); + lowerCutPlane.visible = cutEnabled; + dicomGroup.add(lowerCutPlane); + const upperCutPlane = new THREE.Mesh(new THREE.PlaneGeometry(dicomWidth, dicomHeight), createCutPlaneMaterial()); + upperCutPlane.position.set(0, 0, upperCutZ); + upperCutPlane.visible = cutEnabled; + dicomGroup.add(upperCutPlane); const textures: THREE.Texture[] = []; volume.frames.forEach((frame, index) => { @@ -293,27 +257,6 @@ function FusionThreeView({ dicomGroup.add(slicePlane); }); - if (cutEnabled && volume.frames.length) { - const nearestFrameIndex = volume.indices.reduce((bestIndex, currentIndex, candidateIndex) => ( - Math.abs(currentIndex - cutSlice) < Math.abs((volume.indices[bestIndex] ?? 0) - cutSlice) ? candidateIndex : bestIndex - ), 0); - const cutMaskTexture = createCutMaskTexture(volume.frames[nearestFrameIndex] ?? volume.frames[0], volume.width, volume.height); - if (cutMaskTexture) { - textures.push(cutMaskTexture); - const cutMaskPlane = new THREE.Mesh( - planeGeometry, - new THREE.MeshBasicMaterial({ - map: cutMaskTexture, - transparent: true, - opacity: 0.96, - side: THREE.DoubleSide, - depthWrite: false, - }), - ); - cutMaskPlane.position.set(0, 0, cutZ + 0.018); - dicomGroup.add(cutMaskPlane); - } - } setLoadProgress(42); const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false); @@ -347,7 +290,8 @@ function FusionThreeView({ roughness: solidMode ? 0.56 : 0.48, metalness: 0.03, side: THREE.DoubleSide, - clippingPlanes: cutEnabled ? [clippingPlane] : [], + clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [], + clipIntersection: false, clipShadows: true, }); const mesh = new THREE.Mesh(geometry, material); @@ -470,9 +414,13 @@ function FusionThreeView({ fusionRoot.scale.setScalar(rootPose.scale); if (cutEnabled) { fusionRoot.updateMatrixWorld(true); - const cutNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(fusionRoot.getWorldQuaternion(new THREE.Quaternion())).normalize(); - const cutPoint = new THREE.Vector3(0, 0, cutZ).applyMatrix4(fusionRoot.matrixWorld); - clippingPlane.setFromNormalAndCoplanarPoint(cutNormal, cutPoint); + const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion()); + const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize(); + const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize(); + const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld); + const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld); + lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint); + upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint); } const pose = modelPoseRef.current; @@ -529,7 +477,8 @@ function FusionThreeView({ dicomOpacity.boxOpacity, showBounds, cutEnabled, - cutSlice, + cutStart, + cutEnd, ]); return ( @@ -569,7 +518,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); const [showBounds, setShowBounds] = useState(true); const [cutEnabled, setCutEnabled] = useState(false); - const [cutSlice, setCutSlice] = useState(0); const [moduleStyles, setModuleStyles] = useState>({}); const [savedPoses, setSavedPoses] = useState>([ { id: 'default', name: '默认', pose: defaultModelPose }, @@ -658,9 +606,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { api.getProject(projectId).then((item) => { setProject(item); const maxIndex = Math.max((item.dicomCount || 1) - 1, 0); - setSliceStart(maxIndex); + setSliceStart(0); setSliceEnd(maxIndex); - setCutSlice(maxIndex); setModelPose(defaultModelPose); const nextStyles: Record = {}; (item.stlFiles ?? []).forEach((fileName, index) => { @@ -904,7 +851,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { dicomOpacity={selectedDicomOpacity} showBounds={showBounds} cutEnabled={cutEnabled} - cutSlice={cutSlice} + cutStart={displayStart} + cutEnd={displayEnd} /> ) : (
@@ -1062,18 +1010,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { />
- +

+ 按 DICOM 切片范围 {displayStart + 1}-{displayEnd + 1} 保留模型中间区域 +

diff --git a/工程分析/实现方案-2026-05-08-03-13-20.md b/工程分析/实现方案-2026-05-08-03-13-20.md new file mode 100644 index 0000000..9218c3f --- /dev/null +++ b/工程分析/实现方案-2026-05-08-03-13-20.md @@ -0,0 +1,54 @@ +# 实现方案:DICOM 范围驱动模型切分 + +时间戳:2026-05-08-03-13-20 + +## 修改目标 + +将逆向工作区模型切分从“独立帧滑块 + 单切面 + CUT MASK 贴图”改为“DICOM 切片范围双端点 + 双 clipping plane + 保留中间模型”。 + +## 涉及路径 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 删除 `createCutMaskTexture` 及其调用,彻底去掉 `CUT MASK` 贴图。 +2. 删除 `cutSlice` 状态、初始化和模型切分的帧进度条。 +3. 项目加载时将 `sliceStart` 初始化为 `0`,`sliceEnd` 初始化为 `maxIndex`,让初始范围显示 `1~300`。 +4. `FusionThreeView` 接收 `cutStart`、`cutEnd`,来自 `displayStart`、`displayEnd`。 +5. 根据 DICOM 全序列总数和物理深度,将 `cutStart/cutEnd` 映射为 DICOM Z 坐标。 +6. 创建两个 clipping plane: + - 起点切面:剔除起点外侧。 + - 终点切面:剔除终点外侧。 +7. 模型材质在 `cutEnabled` 时使用两个 clipping plane,`clipIntersection=false`,从而保留两平面之间的中间区域。 +8. 用两张半透明橙色切面辅助显示切割位置,但不再显示任何 mask 图片。 + +## 数据流或交互流程 + +- 用户调整 `DICOM 切片范围`。 +- `displayStart/displayEnd` 归一化端点顺序。 +- 前端按该范围加载 DICOM 纹理,并将同一范围传给 `FusionThreeView` 的模型切割逻辑。 +- 用户启用 `模型切分` 后,STL 模型只显示两帧之间的区域。 + +## 兼容性与回滚方案 + +- 若双 clipping plane 行为异常,可回滚本次 `ReverseWorkspace.tsx` 修改,恢复 `cutSlice` 单切面逻辑。 +- 本次不改 API 和数据文件,回滚只影响前端可视化。 + +## 风险控制 + +- 使用 `npm run lint` 检查 TypeScript。 +- 使用 `npm run build` 检查生产构建。 +- 用 `rg` 确认源码和构建产物不再包含 `CUT MASK` 与 `createCutMaskTexture`。 +- 重新部署后从 dev server 拉取源码确认不再存在模型切分帧滑块。 + +## 预计文件变更 + +- 修改 `ReverseWorkspace.tsx`。 +- 新增本次需求、实现、测试方案文档。 +- 追加 `经验记录.md`。 + +## 人工审核状态 + +用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。 diff --git a/工程分析/测试方案-2026-05-08-03-13-20.md b/工程分析/测试方案-2026-05-08-03-13-20.md new file mode 100644 index 0000000..aff80da --- /dev/null +++ b/工程分析/测试方案-2026-05-08-03-13-20.md @@ -0,0 +1,47 @@ +# 测试方案:DICOM 范围驱动模型切分 + +时间戳:2026-05-08-03-13-20 + +## 静态检查 + +- 执行 `npm run lint`。 +- 执行 `npm run build`。 + +## 关键业务场景验证 + +- 进入逆向工作区,DICOM 切片范围默认显示 `1 - 300 / 300`。 +- 模型切分区域只保留“启用”开关,不再显示“帧”进度条。 +- 调整 DICOM 切片范围后启用模型切分,模型范围外区域被隐藏,保留起点与终点之间的中间区域。 +- 页面不再显示 `CUT MASK` 贴图或文字。 + +## 医学影像数据边界验证 + +- 起止范围相同,如 `300 - 300 / 300`,启用切分时模型只保留该切片附近的薄层。 +- 起止范围反向拖动时,仍按较小值到较大值裁切。 +- 完整范围 `1 - 300 / 300` 下启用切分应基本保留完整模型。 + +## 回归风险 + +- 模型材质 clipping plane 可能受场景旋转影响,需要确认切面跟随 DICOM 体一起旋转。 +- DICOM 范围请求数量较大时仍受后端最大返回帧数限制,但物理空间基准不应变化。 + +## 验收标准 + +- 源码不再包含 `createCutMaskTexture` 和 `CUT MASK`。 +- 源码不再包含 `cutSlice` 状态和模型切分“帧”滑块。 +- 构建与部署成功。 + +## 无法测试的风险 + +- 当前无法在用户浏览器中直接观察 WebGL 裁切结果,需要用户刷新页面后确认视觉效果。 + +## 人工审核状态 + +用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;仅保留 Vite chunk 大小提示。 +- `rg` 验证:源码与最新构建产物不再包含 `cutSlice`、`createCutMaskTexture`、`CUT MASK`、模型切分帧滑块结构。 +- `rg` 验证:项目加载时已执行 `setSliceStart(0)` 和 `setSliceEnd(maxIndex)`,初始范围为完整 DICOM 序列。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 6d5e01e..b6429a0 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -739,3 +739,21 @@ C. 解决问题方案 D. 后续如何避免问题 收到 UI 截图或 DOM 片段时,先用页面可见文案、class 片段和组件文本在所有相关项目目录中定位真实源码,再修改代码。若当前仓库内找不到截图中的文本,必须立即扩大搜索范围并向用户说明实际项目位置,不能默认当前工作目录就是目标项目。 + +## 2026-05-08-03-13-20 模型切分必须由 DICOM 范围统一驱动 + +A. 具体问题 + +逆向工作区初始 DICOM 范围只落在最后一张;模型切分还有独立“帧”滑块,与 DICOM 切片范围割裂;切分开启后额外生成 `CUT MASK` 伪贴图,容易误导用户以为是真实语义分割结果。 + +B. 产生问题原因 + +前一版把“DICOM 显示范围”和“模型切割帧”设计成两套状态,并为了给切割反馈绘制了演示性质的二维 mask 纹理,没有把切分严格绑定到 DICOM 起点和终点双帧。 + +C. 解决问题方案 + +删除 `cutSlice` 状态和模型切分帧滑块,项目加载时将 `sliceStart` 初始化为 `0`、`sliceEnd` 初始化为最大帧;`FusionThreeView` 改接收 `cutStart/cutEnd`,根据 DICOM 物理深度映射为两张 clipping plane,模型切分启用后只保留两帧之间的中间模型区域;删除 `createCutMaskTexture` 和 `CUT MASK` 贴图调用。 + +D. 后续如何避免问题 + +所有模型切分、分割预览、Mask 展示都必须明确数据来源。没有真实体素化或真实语义分割结果时,不应使用伪造 mask 图像;同一业务动作只保留一套权威控制状态,模型切分应默认由 DICOM 切片范围驱动。 diff --git a/工程分析/需求分析-2026-05-08-03-13-20.md b/工程分析/需求分析-2026-05-08-03-13-20.md new file mode 100644 index 0000000..d554f93 --- /dev/null +++ b/工程分析/需求分析-2026-05-08-03-13-20.md @@ -0,0 +1,42 @@ +# 需求分析:模型切分改为 DICOM 范围驱动 + +时间戳:2026-05-08-03-13-20 + +## 原始需求 + +1. 逆向工作区中最开始切片范围应为 `1~300`。 +2. 逆向工作区中“模型切分”下面的帧进度条没有意义,所有切分都按照 `DICOM 切片范围` 来定。 +3. 点击模型切分后,根据 DICOM 帧对模型进行切割,保留 DICOM 中间区域,不要生成 `CUT MASK` 这张图片。 + +## 业务目标 + +- 初始进入逆向工作区时展示完整 DICOM 范围,而不是最后一张。 +- 模型切分只保留一个启用开关,不再有独立帧滑块。 +- 模型切分启用后,使用 DICOM 切片范围的起点和终点作为两把切刀,隐藏范围外 STL,保留范围内模型。 +- 移除伪造的 `CUT MASK` 图片叠加。 + +## 输入与输出 + +- 输入:用户在 DICOM 切片范围条上选择的起止帧。 +- 输出:Three.js 场景中 STL 模型被起止 DICOM 帧裁切,只保留中间区域;DICOM 纹理正常显示选中范围。 + +## 影响范围 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/经验记录.md` + +## 约束 + +- 不改后端 DICOM 读取和 STL preview API。 +- 不生成临时 mask 图像,不显示 `CUT MASK` 文案。 +- 保留现有 DICOM 范围缓存、模型位姿、构件样式、显示档位逻辑。 + +## 风险点 + +- Three.js clipping plane 需要跟随整体融合场景旋转和平移,否则拖拽视角后切面会漂移。 +- 两个 clipping plane 的法线方向必须分别剔除起点外侧和终点外侧,避免把中间区域裁掉。 +- 默认完整范围 `1~300` 下开启切分时,看起来可能没有明显变化,这是符合“保留全部 DICOM 中间区域”的结果。 + +## 待确认事项 + +- 用户已在项目工作流中确认后续直接执行,本次不等待二次人工审核。