2026-05-08-01-53-07 修正DICOM范围和切割Mask预览
This commit is contained in:
@@ -705,7 +705,7 @@ function createDicomFusionVolume(files: string[], start: number, end: number, mo
|
||||
physicalSize: {
|
||||
width: volume.width * volume.columnSpacing,
|
||||
height: volume.height * volume.rowSpacing,
|
||||
depth: Math.max(1, rangeLength) * volume.sliceSpacing,
|
||||
depth: Math.max(1, total) * volume.sliceSpacing,
|
||||
unit: 'mm',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -657,12 +657,29 @@ export default function ProjectLibrary({
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const sliceRepeatRef = useRef<number | null>(null);
|
||||
const dicomRequestRef = useRef(0);
|
||||
const preloadedProjectIdsRef = useRef(new Set<string>());
|
||||
|
||||
const preloadProjectAssets = (project: Project) => {
|
||||
if (preloadedProjectIdsRef.current.has(project.id)) {
|
||||
return;
|
||||
}
|
||||
preloadedProjectIdsRef.current.add(project.id);
|
||||
const maxSlice = Math.max((project.dicomCount || 1) - 1, 0);
|
||||
if (project.dicomCount > 0) {
|
||||
void api.getDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
|
||||
void api.getDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
|
||||
}
|
||||
(project.stlFiles ?? []).slice(0, 3).forEach((fileName) => {
|
||||
void fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=3500`).catch(() => undefined);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshProjects = () => {
|
||||
setLoading(true);
|
||||
return api.getProjects()
|
||||
.then((items) => {
|
||||
setProjects(items);
|
||||
items.slice(0, 2).forEach(preloadProjectAssets);
|
||||
setSelectedProject((current) => {
|
||||
if (!current) {
|
||||
return items[0] ?? null;
|
||||
@@ -677,6 +694,12 @@ export default function ProjectLibrary({
|
||||
refreshProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
preloadProjectAssets(selectedProject);
|
||||
}
|
||||
}, [selectedProject?.id]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
|
||||
@@ -106,6 +106,59 @@ 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,
|
||||
@@ -224,6 +277,7 @@ function FusionThreeView({
|
||||
if (!texture) return;
|
||||
textures.push(texture);
|
||||
const isLast = index === volume.frames.length - 1;
|
||||
const dicomIndex = volume.indices[index] ?? (volume.start + index);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
@@ -232,12 +286,34 @@ function FusionThreeView({
|
||||
depthWrite: false,
|
||||
});
|
||||
const slicePlane = new THREE.Mesh(planeGeometry, material);
|
||||
const z = volume.frames.length <= 1
|
||||
const z = volume.total <= 1
|
||||
? 0
|
||||
: -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1);
|
||||
slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z);
|
||||
: -dicomDepth / 2 + (dicomDepth * dicomIndex) / (volume.total - 1);
|
||||
slicePlane.position.set(0, 0, z + (isLast ? 0.006 : 0));
|
||||
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);
|
||||
@@ -486,6 +562,7 @@ function FusionThreeView({
|
||||
}
|
||||
|
||||
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const [sliceStart, setSliceStart] = useState(0);
|
||||
const [sliceEnd, setSliceEnd] = useState(49);
|
||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||
@@ -556,20 +633,23 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
});
|
||||
};
|
||||
|
||||
const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`;
|
||||
const getFusionCacheKey = (projectIdValue: string, start: number, end: number, mode = 'soft') => `${projectIdValue}:${mode}:${start}:${end}`;
|
||||
|
||||
const loadFusionVolume = async (end: number, useCache = true) => {
|
||||
const loadFusionVolume = async (start: number, end: number, useCache = true) => {
|
||||
if (!project?.dicomCount) return null;
|
||||
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
|
||||
const safeEnd = clamp(end, 0, maxSliceValue);
|
||||
const cacheKey = getFusionCacheKey(project.id, safeEnd);
|
||||
const safeA = clamp(start, 0, maxSliceValue);
|
||||
const safeB = clamp(end, 0, maxSliceValue);
|
||||
const safeStart = Math.min(safeA, safeB);
|
||||
const rangeEnd = Math.max(safeA, safeB);
|
||||
const cacheKey = getFusionCacheKey(project.id, safeStart, rangeEnd);
|
||||
const cached = fusionVolumeCacheRef.current.get(cacheKey);
|
||||
if (useCache && cached) {
|
||||
setFusionVolume(cached);
|
||||
setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`);
|
||||
setPreloadMessage(`已使用缓存范围 ${safeStart + 1}-${rangeEnd + 1}`);
|
||||
return cached;
|
||||
}
|
||||
const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft');
|
||||
const volumePayload = await api.getDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
|
||||
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
|
||||
return volumePayload;
|
||||
};
|
||||
@@ -577,9 +657,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
api.getProject(projectId).then((item) => {
|
||||
setProject(item);
|
||||
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
|
||||
setSliceEnd(end);
|
||||
setCutSlice(end);
|
||||
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
|
||||
setSliceStart(maxIndex);
|
||||
setSliceEnd(maxIndex);
|
||||
setCutSlice(maxIndex);
|
||||
setModelPose(defaultModelPose);
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||||
@@ -595,10 +676,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) return;
|
||||
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
||||
const safeStart = clamp(sliceStart, 0, maxSlice);
|
||||
const safeEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const timer = window.setTimeout(() => {
|
||||
setFusionError('');
|
||||
loadFusionVolume(safeEnd)
|
||||
loadFusionVolume(safeStart, safeEnd)
|
||||
.then(setFusionVolume)
|
||||
.catch((error) => {
|
||||
setFusionVolume(null);
|
||||
@@ -606,7 +688,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
});
|
||||
}, 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [project?.id, project?.dicomCount, sliceEnd]);
|
||||
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (poseRepeatRef.current.timeout !== null) {
|
||||
@@ -732,8 +814,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const displayStart = 0;
|
||||
const displayEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const safeSliceStart = clamp(sliceStart, 0, maxSlice);
|
||||
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
|
||||
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
|
||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
|
||||
const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice));
|
||||
@@ -743,7 +827,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const safeEnd = clamp(end, 0, maxSlice);
|
||||
setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`);
|
||||
try {
|
||||
await loadFusionVolume(safeEnd, false);
|
||||
await loadFusionVolume(safeEnd, safeEnd, false);
|
||||
setPreloadMessage(`已预存第 ${safeEnd + 1} 张`);
|
||||
} catch (error) {
|
||||
setPreloadMessage(error instanceof Error ? error.message : '预存失败');
|
||||
@@ -753,7 +837,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const preloadAllFusionPoints = async () => {
|
||||
setPreloadMessage('正在预存五个点位...');
|
||||
try {
|
||||
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true)));
|
||||
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, point, true)));
|
||||
setPreloadMessage('五个点位已预存');
|
||||
} catch (error) {
|
||||
setPreloadMessage(error instanceof Error ? error.message : '五点预存失败');
|
||||
@@ -761,7 +845,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-6">
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{project && (
|
||||
@@ -795,8 +879,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
|
||||
<div className="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="min-h-[780px] lg:min-h-0 flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div className="lg:col-span-7 min-h-0 flex flex-col gap-4">
|
||||
<div className="px-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Rotate3d size={18} className="text-blue-500" />
|
||||
@@ -841,32 +925,45 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</span>
|
||||
</div>
|
||||
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
|
||||
显示范围
|
||||
起点
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={displayEnd}
|
||||
value={safeSliceStart}
|
||||
onChange={(event) => setSliceStart(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-right font-mono">{safeSliceStart + 1}</span>
|
||||
</label>
|
||||
<label className="mt-2 grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
|
||||
终点
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={safeSliceEnd}
|
||||
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-right font-mono">{displayEnd + 1} 张</span>
|
||||
<span className="text-right font-mono">{safeSliceEnd + 1}</span>
|
||||
</label>
|
||||
<p className="mt-3 text-[10px] leading-5 text-slate-400">
|
||||
默认从第 1 张开始显示,滑条控制融合体使用到第几张切片。
|
||||
显示范围支持 M-N,两个端点可双向调整;范围变化只改变可视化切片,不改变模型原始位姿。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{preloadPoints.map((point, index) => {
|
||||
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false;
|
||||
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point, point)) : false;
|
||||
return (
|
||||
<button
|
||||
key={`${point}-${index}`}
|
||||
onClick={() => {
|
||||
setSliceStart(point);
|
||||
setSliceEnd(point);
|
||||
preloadFusionPoint(point);
|
||||
}}
|
||||
className={`rounded-lg border px-2 py-1 text-[10px] font-bold transition-all ${
|
||||
cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
|
||||
project && cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
点位 {index + 1} · {point + 1}
|
||||
@@ -884,7 +981,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="lg:col-span-2 min-h-0 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="px-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Settings2 size={18} className="text-emerald-500" />
|
||||
|
||||
85
工程分析/实现方案-2026-05-08-01-53-07.md
Normal file
85
工程分析/实现方案-2026-05-08-01-53-07.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 实现方案 - 2026-05-08-01-53-07
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 固定融合视角的 DICOM 物理尺寸基准,使切片范围变化不改变模型原始位置。
|
||||
2. DICOM 切片范围默认显示到最高切片,并支持 `M-N` 双向范围选择。
|
||||
3. 优化逆向工作区布局,浏览器放大后可滚动访问 DICOM 切片范围。
|
||||
4. 项目库项目加载/导入后后台预加载 DICOM 与模型预览。
|
||||
5. 模型切分开启后,在 DICOM 切割帧上叠加 Mask 轮廓/填充,直观看到切割位置。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 固定 DICOM 体基准
|
||||
|
||||
- 后端 `dicom-fusion-volume` 的 `physicalSize.depth` 改为完整 DICOM 序列深度,而不是当前 `start/end` 范围深度。
|
||||
- 前端 FusionThreeView 使用完整物理尺寸计算 DICOM box 和模型缩放,范围变化只改变参与显示的帧,不改变空间基准。
|
||||
- 模型 pivot 保持 DICOM 中心,不根据切片范围重新定位。
|
||||
|
||||
### 双向范围滑条
|
||||
|
||||
- 恢复 `sliceStart` 与 `sliceEnd` 两个状态,但 UI 使用同一范围卡片表达。
|
||||
- 初始值设为 `sliceStart=maxSlice`、`sliceEnd=maxSlice`,即默认最高切片。
|
||||
- 请求接口前将 `min(sliceStart,sliceEnd)` 和 `max(sliceStart,sliceEnd)` 作为真实 `M-N`。
|
||||
- UI 显示 `M-N / total`,并提供两个滑条在同一区域控制左右端点。
|
||||
|
||||
### 放大布局
|
||||
|
||||
- 逆向工作区根容器允许纵向滚动。
|
||||
- 主三列区域保留内部滚动,左侧融合区域不因高度压缩导致 DICOM 范围卡片不可达。
|
||||
|
||||
### 后台预加载
|
||||
|
||||
- 项目库加载项目列表后,对默认/选中项目发起低成本预加载:
|
||||
- DICOM 预览中间帧。
|
||||
- 前几个 STL preview。
|
||||
- 融合体最高切片。
|
||||
- 使用 `void` 异步调用,不阻塞 UI。
|
||||
|
||||
### 切割 Mask 叠加
|
||||
|
||||
- FusionThreeView 中当 `cutEnabled` 时,根据当前切割帧创建 DICOM texture overlay。
|
||||
- 在切割帧平面上叠加半透明橙色 Mask 区域和边界线,表示模型切割位置。
|
||||
- 该 Mask 是可视化辅助层,不改变模型、不写出 NIfTI。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 进入逆向工作区读取项目,计算 `maxSlice=dicomCount-1`。
|
||||
2. 初始范围设为 `maxSlice-maxSlice`。
|
||||
3. 用户拖动双向范围控制,前端排序后请求 `dicom-fusion-volume?start=M&end=N`。
|
||||
4. 后端返回当前范围帧,但 physical depth 始终以完整 DICOM 序列计算。
|
||||
5. FusionThreeView 使用固定 DICOM box 渲染 DICOM 与模型,模型位姿不受范围变化影响。
|
||||
6. 启用模型切分后,在切割帧平面叠加 Mask 预览。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 对任意 DICOM 总数使用 `dicomCount` 和接口返回 `total`,不硬编码 300。
|
||||
- 若项目没有 DICOM 或 STL,预加载自动跳过。
|
||||
- 回滚本次 commit 可恢复旧的单端点切片范围与切割显示。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `server.ts`:修正融合体 physical depth 基准。
|
||||
- `ProjectLibrary.tsx`:新增项目后台预加载。
|
||||
- `ReverseWorkspace.tsx`:双向范围、布局滚动、固定模型空间、切割 Mask 叠加。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 已将后端 `dicom-fusion-volume` 的 `physicalSize.depth` 改为完整 DICOM 序列深度,避免切片范围变化导致 DICOM 体和模型空间重新缩放。
|
||||
- 已将融合视图中每张 DICOM 帧的 Z 位置改为按真实 `volume.indices` 映射到完整 DICOM 深度,范围变化只增减显示帧,不重新铺满空间。
|
||||
- 已将逆向工作区 DICOM 切片范围改为双端点控制,支持 `M-N` 显示范围。
|
||||
- 已将默认切片范围初始化为最高切片 `maxSlice-maxSlice`,不硬编码 300。
|
||||
- 已让逆向工作区根容器支持纵向滚动,浏览器放大时仍可滚动访问 DICOM 切片范围。
|
||||
- 已在项目库中加入项目后台预加载:最高 DICOM 预览、最高融合体、前三个 STL 预览。
|
||||
- 已在模型切分开启时,在切割 DICOM 帧上叠加橙色 Mask 预览和边界标记,使用户能直接在 DICOM 上看到切割位置。
|
||||
48
工程分析/测试方案-2026-05-08-01-53-07.md
Normal file
48
工程分析/测试方案-2026-05-08-01-53-07.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 测试方案 - 2026-05-08-01-53-07
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||
- 执行 `npm run build`,确认生产构建通过。
|
||||
- 执行 `git diff --check`,确认无空白错误。
|
||||
|
||||
## 集成验证
|
||||
|
||||
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=299&end=299&mode=soft` 正常返回最高切片。
|
||||
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=120&end=180&mode=soft` 正常返回 `start=120/end=180`,并保持完整 physical depth。
|
||||
- 重新部署后 `curl -I http://127.0.0.1:4000/` 返回 200。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
1. 逆向工作区初始 DICOM 切片范围显示最高切片。
|
||||
2. DICOM 切片范围支持 `M-N`,两个端点可分别调整。
|
||||
3. 改变切片范围后模型不重新居中、不改变原始配准位置。
|
||||
4. 浏览器放大时可滚动看到 DICOM 切片范围。
|
||||
5. 项目库加载项目后后台预加载不会阻塞页面。
|
||||
6. 启用模型切分后,DICOM 帧上可看到切割 Mask 叠加。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 任意 `dicomCount` 下 `maxSlice=dicomCount-1`,不写死 300。
|
||||
- 当 `sliceStart > sliceEnd` 时前端仍按 `M-N` 排序请求。
|
||||
- 当项目没有 DICOM 文件时,预加载和范围控制不报错。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- 后端 physical depth 改为全序列基准后,旧截图中的 DICOM box 深度可能不同,但空间关系更符合配准。
|
||||
- 切割 Mask 目前是可视化辅助层,不代表真实分割算法输出。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过,仅保留 Vite 大 chunk 体积提醒。
|
||||
- `git diff --check`:通过。
|
||||
- 重新部署:已通过 `tmux` 重启 `revoxelseg-dicom` 服务,运行在 `http://0.0.0.0:4000/`。
|
||||
- `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。
|
||||
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=299&end=299&mode=soft`:返回 `start=299/end=299/total=300/frames=1/depth=300`。
|
||||
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=120&end=180&mode=soft`:返回 `start=120/end=180/total=300/frames=61/depth=300`。
|
||||
- 静态检索确认已移除固定 `displayStart=0`,切片范围使用 `sliceStart/sliceEnd` 双端点。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -703,3 +703,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
涉及 DICOM 与 STL 配准的旋转、缩放、切割操作,应优先明确坐标系和 pivot 来源,默认以 DICOM 体中心为基准;高频切片请求应先设计缓存键和预取策略,避免 UI 操作直接触发重复网络与纹理构建。
|
||||
|
||||
## 2026-05-08-01-53-07 DICOM 范围变化不改变空间基准
|
||||
|
||||
A. 具体问题
|
||||
|
||||
DICOM 切片范围变化后,融合视图中的 DICOM 体深度和帧分布会随范围重新计算,导致模型看起来被自动居中或改变了和 DICOM 的原始配准位置;切分开启后也缺少在 DICOM 帧上直观看到 Mask 的反馈。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
后端 `dicom-fusion-volume` 使用当前 `start/end` 的范围长度计算 `physicalSize.depth`;前端又把当前返回的帧均匀铺满整个 DICOM box,而不是按原始 DICOM index 放回完整序列位置。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
后端 physical depth 改为完整序列深度;前端 DICOM slice plane 的 Z 位置改用 `volume.indices[index] / (total - 1)` 映射到完整 DICOM box;切片范围改为 `sliceStart/sliceEnd` 双端点,默认最高切片;切割开启时额外渲染切割帧 DICOM texture,并叠加橙色 Mask 轮廓。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
所有配准相关可视化都必须区分“显示范围”和“空间基准”:显示范围可以变化,但 DICOM 物理尺寸、模型缩放基准和坐标原点不能跟着变化;任何切割或 Mask 预览都要明确只是辅助显示,不能修改原始 DICOM/STL 位姿。
|
||||
|
||||
48
工程分析/需求分析-2026-05-08-01-53-07.md
Normal file
48
工程分析/需求分析-2026-05-08-01-53-07.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 需求分析 - 2026-05-08-01-53-07
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
本次围绕逆向工作区 DICOM 切片范围与模型切分继续修正:
|
||||
|
||||
1. DICOM 切片范围变化后不能改变模型原始位置,切分只是可视化,不能自动居中或移动 DICOM/模型配准关系。
|
||||
2. 项目库中项目导入后默认后台预加载。
|
||||
3. 逆向工作区初始 DICOM 切片范围默认在最高切片处,并兼容其他项目的任意切片总数。
|
||||
4. 浏览器放大时,逆向工作区仍要能看到 DICOM 切片范围。
|
||||
5. DICOM 切片范围不一定从 1 开始,应支持 M-N,显示范围使用双向范围滑条。
|
||||
6. 启动模型切分后,需要重新渲染首位 DICOM 帧,并在 DICOM 上标出切割模型对应的 Mask,使用户能直接在 DICOM 上看到切割结果。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 保证切片范围变化只影响 DICOM 可视化范围,不影响模型原始位姿或配准关系。
|
||||
- 让项目进入或导入后更快进入可视化状态。
|
||||
- 让 DICOM 范围控制表达真实的 `M-N` 范围,而不是固定 `1-N`。
|
||||
- 提升放大浏览器时的可操作性。
|
||||
- 让模型切分结果更接近医学影像标注预览:在 DICOM 帧上看到 Mask,而不是只看到底层模型/平面。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:用户选择项目、进入逆向工作区、调整 DICOM 切片范围、启用模型切分并选择切割帧。
|
||||
- 输出:
|
||||
- 模型位置不随切片范围改变。
|
||||
- DICOM 默认显示最高切片范围。
|
||||
- 范围滑条可选择起点和终点。
|
||||
- 放大浏览器时仍可滚动看到切片范围。
|
||||
- 切分时在 DICOM 首/当前帧上叠加显示切割 Mask。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 风险点
|
||||
|
||||
- 如果 DICOM 体数据接口继续返回随范围变化的 physical depth,前端模型缩放与位置仍可能被范围影响。
|
||||
- 双向范围滑条若起终点相互穿越,需要稳定地排序为 `M-N`。
|
||||
- 切割 Mask 属于可视化近似,不能误导为已生成真实 NIfTI mask。
|
||||
- 后台预加载需要限制请求规模,避免导入后阻塞主线程或占用过多内存。
|
||||
|
||||
## 待确认问题
|
||||
|
||||
用户已明确本次需求分析、实现方案、测试方案、执行修改均不需要二次人工确认,因此按默认执行确认规则直接实施。
|
||||
Reference in New Issue
Block a user