2026-05-08-03-13-20 按DICOM范围切分模型
This commit is contained in:
@@ -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<HTMLDivElement | null>(null);
|
||||
const modelPoseRef = useRef(modelPose);
|
||||
@@ -253,23 +202,38 @@ function FusionThreeView({
|
||||
edges.visible = showBounds;
|
||||
dicomGroup.add(edges);
|
||||
|
||||
const cutZ = volume.total <= 1
|
||||
const sliceToZ = (sliceIndex: number) => (
|
||||
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({
|
||||
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
|
||||
);
|
||||
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.24 : 0,
|
||||
opacity: cutEnabled ? 0.16 : 0,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
cutPlane.position.set(0, 0, cutZ);
|
||||
cutPlane.visible = cutEnabled;
|
||||
dicomGroup.add(cutPlane);
|
||||
});
|
||||
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<DicomOpacityLevel>('low');
|
||||
const [showBounds, setShowBounds] = useState(true);
|
||||
const [cutEnabled, setCutEnabled] = useState(false);
|
||||
const [cutSlice, setCutSlice] = useState(0);
|
||||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
|
||||
{ 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<string, ModuleStyle> = {};
|
||||
(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}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
|
||||
@@ -1062,18 +1010,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid grid-cols-[42px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
帧
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={clamp(cutSlice, 0, maxSlice)}
|
||||
onChange={(event) => setCutSlice(Number(event.target.value))}
|
||||
className="accent-orange-500"
|
||||
/>
|
||||
<span className="text-right font-mono">{clamp(cutSlice, 0, maxSlice) + 1}</span>
|
||||
</label>
|
||||
<p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
|
||||
按 DICOM 切片范围 {displayStart + 1}-{displayEnd + 1} 保留模型中间区域
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
54
工程分析/实现方案-2026-05-08-03-13-20.md
Normal file
54
工程分析/实现方案-2026-05-08-03-13-20.md
Normal file
@@ -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`。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||
47
工程分析/测试方案-2026-05-08-03-13-20.md
Normal file
47
工程分析/测试方案-2026-05-08-03-13-20.md
Normal file
@@ -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 序列。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.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 切片范围驱动。
|
||||
|
||||
42
工程分析/需求分析-2026-05-08-03-13-20.md
Normal file
42
工程分析/需求分析-2026-05-08-03-13-20.md
Normal file
@@ -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 中间区域”的结果。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
- 用户已在项目工作流中确认后续直接执行,本次不等待二次人工审核。
|
||||
Reference in New Issue
Block a user