2026-05-20-00-38-39 对齐FOV并强化网格截面填充
This commit is contained in:
@@ -72,6 +72,7 @@ const defaultModelPose: ModelPose = {
|
||||
};
|
||||
|
||||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const fusionBaseExtent = 4.6;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
@@ -244,7 +245,8 @@ function FusionThreeView({
|
||||
|
||||
setLoadProgress(42);
|
||||
|
||||
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||
const stlFiles = project.stlFiles ?? [];
|
||||
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||
let modelBaseScale = 1;
|
||||
let loadedModels = 0;
|
||||
let failedModels = 0;
|
||||
@@ -258,35 +260,37 @@ function FusionThreeView({
|
||||
})
|
||||
.then((payload) => {
|
||||
if (disposed) return;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||
geometry.computeVertexNormals();
|
||||
const style = moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: moduleColors[index % moduleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
transparent: true,
|
||||
opacity: materialOpacity,
|
||||
roughness: solidMode ? 0.56 : 0.48,
|
||||
metalness: 0.03,
|
||||
side: THREE.DoubleSide,
|
||||
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
|
||||
clipIntersection: false,
|
||||
clipShadows: true,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
modelPivot.add(mesh);
|
||||
if (payload.bounds) {
|
||||
loadedBounds.push({
|
||||
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
|
||||
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
|
||||
});
|
||||
}
|
||||
if (style.visible !== false) {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||
geometry.computeVertexNormals();
|
||||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
transparent: true,
|
||||
opacity: materialOpacity,
|
||||
roughness: solidMode ? 0.56 : 0.48,
|
||||
metalness: 0.03,
|
||||
side: THREE.DoubleSide,
|
||||
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
|
||||
clipIntersection: false,
|
||||
clipShadows: true,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
modelPivot.add(mesh);
|
||||
}
|
||||
loadedModels += 1;
|
||||
setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
|
||||
})
|
||||
@@ -323,7 +327,7 @@ function FusionThreeView({
|
||||
modelPoseGroup.position.set(0, 0, 0);
|
||||
modelPivot.position.set(0, 0, dicomDepth * 0.08);
|
||||
setLoadProgress(100);
|
||||
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
|
||||
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
|
||||
});
|
||||
|
||||
const rootPose = {
|
||||
@@ -812,8 +816,11 @@ interface Point3D {
|
||||
|
||||
interface ModelSceneMetrics {
|
||||
center: Point3D;
|
||||
normalizer: number;
|
||||
viewExtent: number;
|
||||
modelBaseScale: number;
|
||||
modelPivotOffsetZ: number;
|
||||
dicomWidth: number;
|
||||
dicomHeight: number;
|
||||
dicomDepth: number;
|
||||
}
|
||||
|
||||
interface PlaneSegment {
|
||||
@@ -880,7 +887,41 @@ function getGlobalModelBounds(files: string[], previews: Record<string, ModelPre
|
||||
return hasBounds ? bounds : null;
|
||||
}
|
||||
|
||||
function getModelSceneMetrics(files: string[], previews: Record<string, ModelPreviewPayload>): ModelSceneMetrics | null {
|
||||
function getPreviewPhysicalSize(preview: DicomPreview) {
|
||||
const columnSpacing = preview.spacing?.displayX ?? preview.spacing?.column ?? 1;
|
||||
const rowSpacing = preview.spacing?.displayY ?? preview.spacing?.row ?? 1;
|
||||
const width = preview.physicalSize?.width ?? preview.width * columnSpacing;
|
||||
const height = preview.physicalSize?.height ?? preview.height * rowSpacing;
|
||||
|
||||
return {
|
||||
width: Math.max(width, 0.001),
|
||||
height: Math.max(height, 0.001),
|
||||
columnSpacing: Math.max(columnSpacing, 0.001),
|
||||
rowSpacing: Math.max(rowSpacing, 0.001),
|
||||
sliceSpacing: Math.max(preview.spacing?.slice ?? 1, 0.001),
|
||||
};
|
||||
}
|
||||
|
||||
function getFovCanvasSize(preview: DicomPreview) {
|
||||
const physical = getPreviewPhysicalSize(preview);
|
||||
const unit = Math.max(0.001, Math.min(physical.columnSpacing, physical.rowSpacing));
|
||||
const rawWidth = Math.max(1, Math.round(physical.width / unit));
|
||||
const rawHeight = Math.max(1, Math.round(physical.height / unit));
|
||||
const maxDimension = 960;
|
||||
const scale = Math.min(1, maxDimension / Math.max(rawWidth, rawHeight));
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(rawWidth * scale)),
|
||||
height: Math.max(1, Math.round(rawHeight * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function getModelSceneMetrics(
|
||||
files: string[],
|
||||
previews: Record<string, ModelPreviewPayload>,
|
||||
preview: DicomPreview,
|
||||
totalSlices: number,
|
||||
): ModelSceneMetrics | null {
|
||||
const globalBounds = getGlobalModelBounds(files, previews);
|
||||
if (!globalBounds) {
|
||||
return null;
|
||||
@@ -889,7 +930,14 @@ function getModelSceneMetrics(files: string[], previews: Record<string, ModelPre
|
||||
const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001);
|
||||
const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001);
|
||||
const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001);
|
||||
const maxSpan = Math.max(spanX, spanY, spanZ, 0.001);
|
||||
const maxModelSize = Math.max(spanX, spanY, spanZ, 1);
|
||||
const physical = getPreviewPhysicalSize(preview);
|
||||
const physicalDepth = Math.max(totalSlices, 1) * physical.sliceSpacing;
|
||||
const maxPhysical = Math.max(physical.width, physical.height, physicalDepth, 1);
|
||||
const dicomWidth = (physical.width / maxPhysical) * fusionBaseExtent;
|
||||
const dicomHeight = (physical.height / maxPhysical) * fusionBaseExtent;
|
||||
const dicomDepth = Math.max((physicalDepth / maxPhysical) * fusionBaseExtent, 0.18);
|
||||
const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||||
|
||||
return {
|
||||
center: {
|
||||
@@ -897,15 +945,19 @@ function getModelSceneMetrics(files: string[], previews: Record<string, ModelPre
|
||||
y: (globalBounds.min.y + globalBounds.max.y) / 2,
|
||||
z: (globalBounds.min.z + globalBounds.max.z) / 2,
|
||||
},
|
||||
normalizer: 2 / maxSpan,
|
||||
viewExtent: 2.9,
|
||||
modelBaseScale,
|
||||
modelPivotOffsetZ: dicomDepth * 0.08,
|
||||
dicomWidth,
|
||||
dicomHeight,
|
||||
dicomDepth,
|
||||
};
|
||||
}
|
||||
|
||||
function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D {
|
||||
let px = (x - metrics.center.x) * metrics.normalizer * pose.scale;
|
||||
let py = (y - metrics.center.y) * metrics.normalizer * pose.scale;
|
||||
let pz = (z - metrics.center.z) * metrics.normalizer * pose.scale;
|
||||
const scalar = metrics.modelBaseScale * pose.scale;
|
||||
let px = (x - metrics.center.x) * scalar;
|
||||
let py = (y - metrics.center.y) * scalar;
|
||||
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
|
||||
|
||||
const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
|
||||
const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
|
||||
@@ -1024,6 +1076,67 @@ function parseHexColor(color: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function fillInternalMaskHoles(
|
||||
maskData: ImageData,
|
||||
width: number,
|
||||
height: number,
|
||||
rgb: { r: number; g: number; b: number },
|
||||
alpha: 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] || maskData.data[index * 4 + 3] > 0) {
|
||||
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 < outside.length; index += 1) {
|
||||
const offset = index * 4;
|
||||
if (!outside[index] && 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;
|
||||
patchedPixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return patchedPixels;
|
||||
}
|
||||
|
||||
function drawFallbackClosedRegion(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
@@ -1163,6 +1276,7 @@ function fillSegmentsAsSolidMask(
|
||||
}
|
||||
});
|
||||
|
||||
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||||
maskContext.putImageData(maskData, 0, 0);
|
||||
context.drawImage(maskCanvas, 0, 0);
|
||||
if (filledPixels === 0 && segments.length >= 3) {
|
||||
@@ -1187,22 +1301,27 @@ function fillSegmentsAsSolidMask(
|
||||
}
|
||||
|
||||
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
|
||||
canvas.width = preview.width;
|
||||
canvas.height = preview.height;
|
||||
const fovCanvas = getFovCanvasSize(preview);
|
||||
canvas.width = fovCanvas.width;
|
||||
canvas.height = fovCanvas.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binary = atob(preview.pixels);
|
||||
const imageData = context.createImageData(preview.width, preview.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] = 255;
|
||||
const imageData = context.createImageData(fovCanvas.width, fovCanvas.height);
|
||||
for (let y = 0; y < fovCanvas.height; y += 1) {
|
||||
const sourceY = Math.min(preview.height - 1, Math.floor((y / fovCanvas.height) * preview.height));
|
||||
for (let x = 0; x < fovCanvas.width; x += 1) {
|
||||
const sourceX = Math.min(preview.width - 1, Math.floor((x / fovCanvas.width) * preview.width));
|
||||
const value = binary.charCodeAt(sourceY * preview.width + sourceX);
|
||||
const offset = (y * fovCanvas.width + x) * 4;
|
||||
imageData.data[offset] = value;
|
||||
imageData.data[offset + 1] = value;
|
||||
imageData.data[offset + 2] = value;
|
||||
imageData.data[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
@@ -1217,25 +1336,27 @@ function drawVoxelOverlayLayer(
|
||||
slice: number,
|
||||
totalSlices: number,
|
||||
): OverlayStats {
|
||||
canvas.width = preview.width;
|
||||
canvas.height = preview.height;
|
||||
const fovCanvas = getFovCanvasSize(preview);
|
||||
canvas.width = fovCanvas.width;
|
||||
canvas.height = fovCanvas.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { activeModules: 0, filledPixels: 0, segmentCount: 0 };
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, preview.width, preview.height);
|
||||
const metrics = getModelSceneMetrics(files, previews);
|
||||
context.clearRect(0, 0, fovCanvas.width, fovCanvas.height);
|
||||
const metrics = getModelSceneMetrics(files, previews, preview, totalSlices);
|
||||
if (!metrics) {
|
||||
return { activeModules: 0, filledPixels: 0, segmentCount: 0 };
|
||||
}
|
||||
|
||||
const normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1);
|
||||
const targetZ = -1 + normalizedSlice * 2;
|
||||
const canvasScale = Math.min(preview.width, preview.height) / (metrics.viewExtent * 2);
|
||||
const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0));
|
||||
const targetZ = totalSlices <= 1
|
||||
? 0
|
||||
: -metrics.dicomDepth / 2 + (metrics.dicomDepth * safeSlice) / (totalSlices - 1);
|
||||
const mapPoint = (point: Point2D): Point2D => ({
|
||||
x: preview.width / 2 + point.x * canvasScale,
|
||||
y: preview.height / 2 - point.y * canvasScale,
|
||||
x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * fovCanvas.width,
|
||||
y: fovCanvas.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * fovCanvas.height,
|
||||
});
|
||||
let activeModules = 0;
|
||||
let filledPixels = 0;
|
||||
@@ -1288,7 +1409,7 @@ function drawVoxelOverlayLayer(
|
||||
}
|
||||
}
|
||||
|
||||
const modulePixels = fillSegmentsAsSolidMask(context, preview.width, preview.height, segments, style.color, style.opacity);
|
||||
const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity);
|
||||
if (segments.length > 0) {
|
||||
activeModules += 1;
|
||||
}
|
||||
@@ -1364,7 +1485,7 @@ function VoxelizationMappingView({
|
||||
let disposed = false;
|
||||
setOverlayStatus('正在载入 STL 构件层级...');
|
||||
Promise.allSettled(stlFiles.map((fileName) => (
|
||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`)
|
||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('STL 构件预览加载失败');
|
||||
|
||||
67
工程分析/实现方案-2026-05-20-00-38-39.md
Normal file
67
工程分析/实现方案-2026-05-20-00-38-39.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 实现方案-2026-05-20-00-38-39
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-00-38-39.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将右侧“逆向分割映射视图”的坐标映射改为与左侧“三维融合视图”同源的 DICOM 物理 FOV,并强化 STL Mesh-Plane Intersection 与实体填充,减少尺度错位和截面漏隙。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-00-38-39.md`
|
||||
- `工程分析/实现方案-2026-05-20-00-38-39.md`
|
||||
- `工程分析/测试方案-2026-05-20-00-38-39.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 新增统一空间指标计算:
|
||||
- 从 DICOM `physicalSize.width/height` 和 `spacing.slice` 推导物理深度。
|
||||
- 复用左侧三维融合视图 `baseExtent=4.6` 的归一化策略。
|
||||
- 计算 `dicomWidth/dicomHeight/dicomDepth`、当前 slice 对应 Z 坐标、STL bounds 中心和 `modelBaseScale`。
|
||||
- 左侧三维视图也改为使用全部 STL bounds 作为全局模型尺度,隐藏构件只影响渲染,不再改变全局比例。
|
||||
2. 修改右侧 STL 顶点变换:
|
||||
- 按左侧三维层级顺序复现 `modelPivot` 和 `modelPoseGroup`。
|
||||
- 使用 `modelBaseScale * pose.scale`、旋转、平移和 pivot Z 偏移。
|
||||
3. 修改右侧画布坐标映射:
|
||||
- `x` 映射到 `[-dicomWidth/2, dicomWidth/2]`。
|
||||
- `y` 映射到 `[-dicomHeight/2, dicomHeight/2]`。
|
||||
- 画布宽高仍为 DICOM preview 像素,物理 FOV 与 Base Layer 完全一致。
|
||||
4. 提升 Overlay 数据精度:
|
||||
- 右侧 STL preview 请求改为 `200000` 上限,尽量使用完整网格。
|
||||
- 继续逐三角面执行 Mesh-Plane Intersection,得到截面线段。
|
||||
5. 强化实体填充:
|
||||
- 扫描线收集截面线段交点并进行内部填充。
|
||||
- 对填充结果做边界外 flood fill,自动补齐内部未填孔洞。
|
||||
- 使用离屏 canvas 合成每个构件,避免多构件透明区域互相擦除。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 更新右侧空间指标、位姿变换和点到画布映射函数。
|
||||
- 更新左侧三维视图的模型 bounds 计算,保证构件显隐不改变全局 FOV。
|
||||
- 更新 STL preview 请求 limit。
|
||||
- 更新实体填充函数,加入内部孔洞填充。
|
||||
- 执行 `npm run lint` 和 `npm run build`。
|
||||
- 重新部署并验证服务。
|
||||
- 追加经验记录并提交推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 不改变外部 API 和状态结构。
|
||||
- 若新 FOV 映射异常,可回退到上一版 `drawVoxelOverlayLayer` 的归一化映射。
|
||||
- 若最大 STL preview 请求导致性能压力,可降低右侧独立请求 limit,但保留同源 FOV 计算。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ReverseWorkspace.tsx`:更新 FOV 坐标、模型变换、填充算法和请求精度。
|
||||
- 新增本次三份工程文档。
|
||||
- 更新 `经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 提交信息:`2026-05-20-00-38-39 对齐FOV并强化网格截面填充`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 使用 Gitea origin/main 备份并重新部署 `revoxelseg-dicom`。
|
||||
61
工程分析/测试方案-2026-05-20-00-38-39.md
Normal file
61
工程分析/测试方案-2026-05-20-00-38-39.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 测试方案-2026-05-20-00-38-39
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-20-00-38-39.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 在 `WebSite/` 下执行 `npm run lint`。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 下执行 `npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 打开逆向工作区,确认右侧仍为“逆向分割映射视图”。
|
||||
- 右侧 Base Layer 与 Overlay 使用同一画布尺寸,不出现拉伸错位。
|
||||
- 拖动右侧 Slice Navigator,Overlay 的 Z 截面按 DICOM 物理深度变化。
|
||||
- 拖动中部 X/Y/Z 平移、旋转和缩放,右侧 Overlay 与左侧三维模型位姿同步变化。
|
||||
- 调整构件颜色、透明度、显示隐藏后,右侧实体 Mask 实时联动。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- DICOM spacing 缺失时使用合理默认值,不导致页面报错。
|
||||
- STL preview 不可用时,DICOM Base Layer 仍显示。
|
||||
- STL 截面没有形成闭合区域时不应导致页面崩溃。
|
||||
- 多构件同时显示时,不应因透明 ImageData 覆盖导致已绘制构件消失。
|
||||
- 内部小孔洞应被补齐为实体 Mask,减少漏隙。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证:
|
||||
- `curl http://127.0.0.1:4000/api/health`
|
||||
- `curl -I http://127.0.0.1:4000/`
|
||||
- DICOM preview 接口。
|
||||
- STL preview 接口。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 显式暂存本次相关代码和文档。
|
||||
- 创建包含时间戳和描述的 commit。
|
||||
- 推送到 Gitea `origin/main`。
|
||||
|
||||
## 回归关注点
|
||||
|
||||
- 不影响左侧三维融合视图本身的加载和交互。
|
||||
- 不影响 DICOM 切片范围控件。
|
||||
- 不影响 Mask 导出按钮。
|
||||
|
||||
## 实际执行结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;Vite 保留既有 chunk 体积提示,不影响构建产物生成。
|
||||
- 部署:已重启 `tmux` 会话 `revoxelseg-dicom`,服务日志显示 `ReVoxelSeg DICOM server ready at http://0.0.0.0:4000/`。
|
||||
- `curl http://127.0.0.1:4000/api/health`:通过,返回 `{"ok":true,"service":"revoxelseg-dicom"}`。
|
||||
- `curl -I http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`。
|
||||
- `curl` 验证项目接口:通过,返回示例项目。
|
||||
- `curl` 验证 DICOM preview:通过,返回 `physicalSize.width=400`、`physicalSize.height=400`。
|
||||
- `curl` 验证 STL preview `limit=200000`:通过,`气管上段.stl` 返回 `triangleCount=136500`、`sampledTriangles=136500`,说明该构件已使用完整三角网格。
|
||||
36
工程分析/经验记录.md
36
工程分析/经验记录.md
@@ -919,3 +919,39 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
多层或多构件 Canvas 叠加时,优先使用离屏画布或单次合并后的 ImageData;只有在明确要替换整张图时才使用主 canvas 的 `putImageData`。涉及透明叠加的渲染改动,必须检查“前一层是否会被透明像素擦除”。
|
||||
|
||||
## 2026-05-20-00-38-39 多视图 FOV 对齐必须使用同源物理尺度
|
||||
|
||||
A. 具体问题
|
||||
|
||||
右侧“逆向分割映射视图”此前使用独立归一化 `viewExtent` 映射 STL 截面,左侧三维融合视图则根据 DICOM 物理尺寸和 `baseExtent` 计算显示尺寸;两侧视场和缩放来源不同,容易出现同一层切片下解剖结构和 Overlay Mask 大小不一致。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
右侧算法只使用 STL bounds 做归一化,没有读取 DICOM `physicalSize`、`Pixel Spacing` 和 slice spacing;同时左侧三维视图过去按当前可见 STL 构件计算 bounds,构件显隐还可能改变左侧模型缩放。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
右侧新增 `getPreviewPhysicalSize`、`getFovCanvasSize` 和同源 `getModelSceneMetrics`,用 DICOM 物理宽高、层厚、总层数和左侧 `baseExtent=4.6` 推导 `dicomWidth/dicomHeight/dicomDepth`、slice Z 与 `modelBaseScale`。左侧三维视图改为始终用全部 STL bounds 建立全局模型尺度,构件显隐只影响 Mesh 是否渲染。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
新增任何二维/三维联动视图前,先明确使用哪套物理坐标系和 FOV;DICOM Base Layer、STL Overlay、slice Z 和模型缩放必须从同一组 spacing、physicalSize 与全局 bounds 推导,不能在不同视图中各自定义归一化范围。
|
||||
|
||||
## 2026-05-20-00-38-39 实体 Mask 填充需要补齐内部空洞
|
||||
|
||||
A. 具体问题
|
||||
|
||||
仅依赖扫描线交点配对填充时,复杂 STL 截面或抽样边界可能产生小孔洞,视觉上仍会像漏隙,不符合“闭合实体区域”Mask 的审查预期。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
Mesh-Plane Intersection 得到的是离散截面线段,浏览器端再进行像素级光栅化;线段精度、重复交点、边界浮点误差和抽样上限都可能让填充区域内部残留透明像素。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
在每个构件的离屏 Mask ImageData 中完成扫描线填充后,从画布四边对透明区域做 flood fill 标记外部背景;任何未被外部连通到的透明像素都视为内部孔洞并填为当前构件颜色,再合成回主 Overlay。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
所有实体 Mask 光栅化都应包含“内部孔洞填补”或等价的连通域后处理;验证时不能只看边界是否有线,还要检查内部是否连续饱满。若后续接入后端体素化,也应保留连通域清理策略作为质量检查。
|
||||
|
||||
51
工程分析/需求分析-2026-05-20-00-38-39.md
Normal file
51
工程分析/需求分析-2026-05-20-00-38-39.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 需求分析-2026-05-20-00-38-39
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-20-00-38-39
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求继续优化右侧“逆向分割映射视图”:一是让左侧三维融合视图与右侧二维切面视图严格使用 DICOM 物理像素间距和统一 FOV,解决视觉尺度不一致;二是进一步强化右侧 Label Map 的生成算法,明确以 STL Mesh 与当前切片平面做数学求交,并对闭合轮廓做实体化光栅填充,消除散点漏隙。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 右侧二维 Base/Overlay 与左侧三维融合场景共享同一套 DICOM 物理视场。
|
||||
- 当前切片位置的 Z 坐标由 DICOM slice spacing 和 total slices 决定,不能再使用任意归一化视场。
|
||||
- STL Overlay 的大小、位置和缩放需与三维融合视图中的 STL 模型一致。
|
||||
- 右侧 Mask 由 Mesh-Plane Intersection 得到的截面线段/轮廓生成,并填充为连续实体区域。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:
|
||||
- DICOM preview 的 `spacing`、`physicalSize`、切片序号与总层数。
|
||||
- STL preview 的三角网格顶点、bounds 与 triangleCount。
|
||||
- 中部工具栏当前 `modelPose`。
|
||||
- 构件层级 `moduleStyles`。
|
||||
- 输出:
|
||||
- 与左侧三维视图物理 FOV 对齐的右侧 DICOM Base Layer。
|
||||
- 与当前切片平面求交后的 STL 截面轮廓。
|
||||
- 填满内部孔洞、透明度可调、可与构件层级联动的实体 Label Map。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- 本次工程分析文档与 `工程分析/经验记录.md`
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 不修改后端 API,不引入新依赖。
|
||||
- 右侧视图必须继续使用真实 DICOM preview 和 STL preview 数据,不生成无来源伪 Mask。
|
||||
- 右侧 Slice Navigator 仍保持独立,不影响左侧 DICOM 范围状态。
|
||||
- 本次提交不能混入历史 `工程分析` 文档删除状态。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 当前 STL preview 接口最高限制 200000 个三角面,若模型超过该上限,仍可能存在抽样误差。
|
||||
- 三维融合视图历史上对 STL 使用 bounds 居中和统一缩放,并非真实 DICOM patient coordinate 注册;本次目标是在现有三维融合坐标系内做到左右 FOV 严格一致。
|
||||
- 多构件或多轮廓实体填充时,需要避免透明像素覆盖已绘制构件,并处理闭合轮廓内部空洞。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 用户要求的是当前产品演示坐标系下的视觉/物理 FOV 对齐,而不是新增后端医学配准算法。
|
||||
- 右侧高精度几何切割优先使用 STL preview 的最大可用三角面数量。
|
||||
Reference in New Issue
Block a user