2026-05-19-23-47-31 优化逆向分割映射视图

This commit is contained in:
2026-05-19 23:56:24 +08:00
parent f730a1c48b
commit 2e04e2d5f9
6 changed files with 671 additions and 10 deletions

View File

@@ -5,12 +5,14 @@ import {
Download, Download,
Rotate3d, Rotate3d,
AlertCircle, AlertCircle,
Play, ChevronLeft,
ChevronRight,
Eye, Eye,
Layers,
Save, Save,
} from 'lucide-react'; } from 'lucide-react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DicomFusionVolume, ModuleStyle, Project } from '../types'; import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api'; import { api, downloadMask } from '../lib/api';
interface ModelPose { interface ModelPose {
@@ -792,9 +794,398 @@ function CutSectionPreview({
); );
} }
interface ModelBounds {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}
function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
if (payload.bounds) {
return payload.bounds;
}
if (payload.vertices.length < 3) {
return null;
}
const bounds: ModelBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
for (let index = 0; index < payload.vertices.length; index += 3) {
const x = payload.vertices[index];
const y = payload.vertices[index + 1];
const z = payload.vertices[index + 2];
bounds.min.x = Math.min(bounds.min.x, x);
bounds.min.y = Math.min(bounds.min.y, y);
bounds.min.z = Math.min(bounds.min.z, z);
bounds.max.x = Math.max(bounds.max.x, x);
bounds.max.y = Math.max(bounds.max.y, y);
bounds.max.z = Math.max(bounds.max.z, z);
}
return Number.isFinite(bounds.min.x) ? bounds : null;
}
function getGlobalModelBounds(files: string[], previews: Record<string, ModelPreviewPayload>) {
const bounds: ModelBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
let hasBounds = false;
files.forEach((fileName) => {
const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null;
if (!payloadBounds) {
return;
}
hasBounds = true;
bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x);
bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y);
bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z);
bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x);
bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y);
bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z);
});
return hasBounds ? bounds : null;
}
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
canvas.width = preview.width;
canvas.height = preview.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;
}
context.putImageData(imageData, 0, 0);
}
function drawVoxelOverlayLayer(
canvas: HTMLCanvasElement,
preview: DicomPreview,
files: string[],
previews: Record<string, ModelPreviewPayload>,
moduleStyles: Record<string, ModuleStyle>,
slice: number,
totalSlices: number,
) {
canvas.width = preview.width;
canvas.height = preview.height;
const context = canvas.getContext('2d');
if (!context) {
return { activeModules: 0, paintedTriangles: 0 };
}
context.clearRect(0, 0, preview.width, preview.height);
const globalBounds = getGlobalModelBounds(files, previews);
if (!globalBounds) {
return { activeModules: 0, paintedTriangles: 0 };
}
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 normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1);
const targetZ = globalBounds.min.z + normalizedSlice * spanZ;
const sliceBand = Math.max(spanZ / Math.max(totalSlices, 1) * 1.85, spanZ * 0.014, 0.001);
const paddingX = preview.width * 0.08;
const paddingY = preview.height * 0.08;
const drawableWidth = Math.max(preview.width - paddingX * 2, 1);
const drawableHeight = Math.max(preview.height - paddingY * 2, 1);
const mapX = (x: number) => paddingX + ((x - globalBounds.min.x) / spanX) * drawableWidth;
const mapY = (y: number) => preview.height - paddingY - ((y - globalBounds.min.y) / spanY) * drawableHeight;
let activeModules = 0;
let paintedTriangles = 0;
context.save();
context.lineJoin = 'round';
context.lineCap = 'round';
context.globalCompositeOperation = 'source-over';
files.forEach((fileName, index) => {
const payload = previews[fileName];
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
if (!payload || style.visible === false) {
return;
}
let modulePainted = false;
context.fillStyle = style.color;
context.strokeStyle = style.color;
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
context.lineWidth = Math.max(preview.width, preview.height) * 0.0015;
for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) {
const z1 = payload.vertices[vertexIndex + 2];
const z2 = payload.vertices[vertexIndex + 5];
const z3 = payload.vertices[vertexIndex + 8];
const minZ = Math.min(z1, z2, z3);
const maxZ = Math.max(z1, z2, z3);
if (minZ > targetZ + sliceBand || maxZ < targetZ - sliceBand) {
continue;
}
context.beginPath();
context.moveTo(mapX(payload.vertices[vertexIndex]), mapY(payload.vertices[vertexIndex + 1]));
context.lineTo(mapX(payload.vertices[vertexIndex + 3]), mapY(payload.vertices[vertexIndex + 4]));
context.lineTo(mapX(payload.vertices[vertexIndex + 6]), mapY(payload.vertices[vertexIndex + 7]));
context.closePath();
context.fill();
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.72;
context.stroke();
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
modulePainted = true;
paintedTriangles += 1;
}
if (modulePainted) {
activeModules += 1;
}
});
context.restore();
return { activeModules, paintedTriangles };
}
function VoxelizationMappingView({
project,
moduleStyles,
detailLimit,
slice,
totalSlices,
onSliceChange,
}: {
project: Project | null;
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
slice: number;
totalSlices: number;
onSliceChange: (slice: number) => void;
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 });
const maxSlice = Math.max(totalSlices - 1, 0);
const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? [];
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
useEffect(() => {
if (!project?.dicomCount) {
setDicomPreview(null);
setDicomStatus('没有可显示的 DICOM 切片');
return;
}
let disposed = false;
setDicomStatus('正在载入 DICOM Base Layer...');
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft')
.then((preview) => {
if (disposed) return;
setDicomPreview(preview);
setDicomStatus('DICOM Base Layer 已就绪');
})
.catch((error) => {
if (disposed) return;
setDicomPreview(null);
setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败');
});
return () => {
disposed = true;
};
}, [project?.id, project?.dicomCount, safeSlice]);
useEffect(() => {
if (!project || !stlFiles.length) {
setModelPreviews({});
setOverlayStatus('当前项目没有 STL 构件');
return;
}
let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`)
.then((response) => {
if (!response.ok) {
throw new Error('STL 构件预览加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => ({ fileName, payload }))
))).then((results) => {
if (disposed) return;
const nextPreviews: Record<string, ModelPreviewPayload> = {};
results.forEach((result) => {
if (result.status === 'fulfilled') {
nextPreviews[result.value.fileName] = result.value.payload;
}
});
setModelPreviews(nextPreviews);
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
});
return () => {
disposed = true;
};
}, [project?.id, stlFiles.join('|'), detailLimit]);
useEffect(() => {
const canvas = baseCanvasRef.current;
if (!canvas || !dicomPreview) {
return;
}
drawDicomBaseLayer(canvas, dicomPreview);
}, [dicomPreview]);
useEffect(() => {
const canvas = overlayCanvasRef.current;
if (!canvas || !dicomPreview) {
return;
}
const stats = drawVoxelOverlayLayer(
canvas,
dicomPreview,
stlFiles,
modelPreviews,
moduleStyles,
safeSlice,
Math.max(totalSlices, 1),
);
setOverlayStats(stats);
}, [dicomPreview, stlFiles.join('|'), modelPreviews, JSON.stringify(moduleStyles), safeSlice, totalSlices]);
const stepSlice = (delta: number) => {
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
};
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
return (
<div className="relative flex h-full min-h-[420px] flex-col overflow-hidden rounded-3xl border border-slate-900 bg-slate-950 shadow-2xl">
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-200">
Base DICOM
</span>
<span className="rounded-lg border border-cyan-300/20 bg-cyan-950/70 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-100">
Overlay Label Map
</span>
</div>
<div className="absolute right-4 top-4 z-10 rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[10px] font-mono text-white/70">
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
</div>
{dicomPreview ? (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain mix-blend-screen" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
{dicomStatus}
</div>
)}
<div className="pointer-events-none absolute inset-x-4 bottom-4 z-10 rounded-2xl border border-white/10 bg-black/70 p-3 backdrop-blur-sm">
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
<span className="truncate">{overlayStatus}</span>
<span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.paintedTriangles}
</span>
</div>
<div className="grid max-h-20 grid-cols-1 gap-1 overflow-auto pr-1">
{stlFiles.map((fileName, index) => {
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
return (
<div key={fileName} className={`flex items-center gap-2 text-[9px] font-bold ${style.visible ? 'text-white/70' : 'text-white/25'}`}>
<span className="h-2.5 w-2.5 rounded-sm border border-white/20" style={{ backgroundColor: style.color, opacity: style.visible ? style.opacity : 0.25 }} />
<span className="min-w-0 flex-1 truncate">{fileName.replace(/\.stl$/i, '')}</span>
<span className="font-mono">ID {style.partId}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
<span className="font-mono text-[10px] font-bold text-cyan-100">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
</span>
</div>
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
<button
onClick={() => stepSlice(-1)}
disabled={safeSlice <= 0}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="上一层"
>
<ChevronLeft size={15} />
</button>
<div className="relative h-8">
<div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-800" />
<div
className="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-cyan-400"
style={{ left: 0, width: `${slicePercent}%` }}
/>
<input
type="range"
min="0"
max={maxSlice}
value={safeSlice}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-input"
aria-label="逆向分割映射视图切片导航"
/>
</div>
<button
onClick={() => stepSlice(1)}
disabled={safeSlice >= maxSlice}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="下一层"
>
<ChevronRight size={15} />
</button>
</div>
</div>
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) { export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0); const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49); const [sliceEnd, setSliceEnd] = useState(49);
const [mappingSlice, setMappingSlice] = useState(0);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose); const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard'); const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low'); const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
@@ -879,6 +1270,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0); const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
setSliceStart(0); setSliceStart(0);
setSliceEnd(maxIndex); setSliceEnd(maxIndex);
setMappingSlice(maxIndex);
setModelPose(defaultModelPose); setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {}; const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => { (item.stlFiles ?? []).forEach((fileName, index) => {
@@ -1034,6 +1426,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0); const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const safeSliceStart = clamp(sliceStart, 0, maxSlice); const safeSliceStart = clamp(sliceStart, 0, maxSlice);
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice); const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
const displayStart = Math.min(safeSliceStart, safeSliceEnd); const displayStart = Math.min(safeSliceStart, safeSliceEnd);
const displayEnd = Math.max(safeSliceStart, safeSliceEnd); const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0; const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
@@ -1416,8 +1809,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden"> <div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0"> <div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2"> <h3 className="font-bold text-slate-700 flex items-center gap-2">
<Play size={18} className="text-blue-500" /> <Layers size={18} className="text-cyan-500" />
Mask
</h3> </h3>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -1439,15 +1832,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div> </div>
</div> </div>
<CutSectionPreview <VoxelizationMappingView
project={project} project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles} moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit} detailLimit={selectedDisplay.limit}
cutEnabled={cutEnabled} slice={safeMappingSlice}
cutStart={displayStart} totalSlices={project?.dicomCount ?? 0}
cutEnd={displayEnd} onSliceChange={setMappingSlice}
/> />
</div> </div>
</div> </div>

View File

@@ -59,3 +59,60 @@
.dicom-range-input:active::-moz-range-thumb { .dicom-range-input:active::-moz-range-thumb {
cursor: grabbing; cursor: grabbing;
} }
.mapping-slice-input {
appearance: none;
-webkit-appearance: none;
background: transparent;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.mapping-slice-input:focus {
outline: none;
}
.mapping-slice-input::-webkit-slider-runnable-track {
background: transparent;
border: 0;
height: 8px;
}
.mapping-slice-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 22px;
margin-top: -7px;
width: 22px;
}
.mapping-slice-input::-moz-range-track {
background: transparent;
border: 0;
height: 8px;
}
.mapping-slice-input::-moz-range-thumb {
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 16px;
width: 16px;
}
.mapping-slice-input:active::-webkit-slider-thumb {
cursor: grabbing;
}
.mapping-slice-input:active::-moz-range-thumb {
cursor: grabbing;
}

View File

@@ -0,0 +1,57 @@
# 实现方案-2026-05-19-23-47-31
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-23-47-31.md`
## 修改目标
将逆向工作区右侧 `Mask 展示` 改造成二维多图层“逆向分割映射视图”,实现 DICOM Base Layer、STL 逆向投影 Overlay Label Map、构件层级属性联动和独立 Slice Navigator。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/index.css`
- `工程分析/需求分析-2026-05-19-23-47-31.md`
- `工程分析/实现方案-2026-05-19-23-47-31.md`
- `工程分析/测试方案-2026-05-19-23-47-31.md`
- `工程分析/经验记录.md`
## 技术路线
1.`ReverseWorkspace.tsx` 中新增独立 `mappingSlice` 状态。
2. 将右侧 `CutSectionPreview` 替换为 `VoxelizationMappingView`
3. `VoxelizationMappingView` 使用两个叠放 Canvas
- Base Canvas 绘制 `api.getDicomPreview(project.id, mappingSlice, 'axial', 'soft')` 返回的 DICOM 灰度像素。
- Overlay Canvas 根据 STL preview 顶点、当前切片 Z 位置和 `moduleStyles` 生成 Label Map 投影。
4. Overlay 绘制规则:
- 对每个可见 STL 构件读取颜色、透明度、`partId`
- 将 STL 顶点 bounds 归一化映射到 DICOM canvas。
- 只绘制与当前 Z 切片带宽相交的三角面。
- 隐藏构件不绘制,透明度随构件层级滑条变化。
5. 在右侧视图底部添加专属 Slice Navigator
- 独立范围为 `0 ~ project.dicomCount - 1`
- 支持拖动和左右单步按钮。
- 不修改左侧 DICOM 范围,也不影响三维融合切分状态。
6. 保留 NII/NII.GZ 导出按钮。
7. 为新滑条补充稳定尺寸和样式,避免 UI 抖动。
## 兼容性与回滚方案
- 不修改后端接口和数据结构,回滚时只需恢复 `ReverseWorkspace.tsx``index.css`
- 如果 STL preview 加载失败,右侧仍显示 DICOM Base Layer并显示 Overlay 状态提示。
- 如果 DICOM preview 加载失败,显示加载或错误状态,不影响左侧三维融合视图。
## 预计文件变更
- 更新 `ReverseWorkspace.tsx`:新增二维映射视图、独立切片状态、右侧标题与图层联动。
- 更新 `index.css`:新增独立 Slice Navigator 滑条样式。
- 新增本次三份工程文档。
- 更新 `经验记录.md`
## 提交与部署策略
- 仅暂存本次代码和文档文件。
- 提交信息使用:`2026-05-19-23-47-31 优化逆向分割映射视图`
- 运行 `npm run lint``npm run build`
- 重新部署 `tmux` 会话 `revoxelseg-dicom`,验证 `http://127.0.0.1:4000/api/health` 和首页响应。

View File

@@ -0,0 +1,63 @@
# 测试方案-2026-05-19-23-47-31
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-23-47-31.md`
## 静态检查
-`WebSite/` 下执行 `npm run lint`
## 构建检查
-`WebSite/` 下执行 `npm run build`
## 关键业务场景验证
- 打开逆向工作区,确认右侧标题为“逆向分割映射视图”。
- 确认右侧视图显示 DICOM Base Layer而不是纯 STL 三维实体切面。
- 确认 Overlay Layer 会显示与 STL 构件对应的彩色 Label Map。
- 在中间“构件层级”面板修改构件颜色,右侧 Overlay 即时变色。
- 修改构件透明度,右侧 Overlay 透明度即时变化。
- 隐藏某个构件,右侧 Overlay 中对应构件消失。
- 调整构件 `ID` 后,右侧图例中的 Label ID 与构件层级保持一致。
- 拖动右侧 Slice NavigatorDICOM 切片和 Overlay 逐层切换。
- 确认右侧 Slice Navigator 不改变左侧 DICOM 切片范围。
## 医学影像数据相关边界验证
- DICOM 切片总数为 0 或项目加载中时,右侧应显示加载/空状态。
- STL preview 加载失败时DICOM Base Layer 仍可显示。
- 切片序号必须 clamp 到 `1 ~ dicomCount`
- Canvas Base Layer 与 Overlay Layer 必须尺寸一致。
## 部署验证
- 重新部署后验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 显式暂存本次相关代码和文档,避免提交历史删除状态。
- 创建包含时间戳和描述的 commit。
- 尝试推送到 `origin/main`;若仍因 Gitea HTTP 凭据失败,则记录本地 commit 已完成、远端推送未完成。
## 回归关注点
- 右侧视图改为 2D Canvas 后,不能影响左侧 `FusionThreeView`
- 构件层级状态仍通过 `api.updateProjectModuleStyles` 持久化。
- 不引入新的后端依赖。
## 实际执行结果
- `npm run lint`:通过。
- `npm run build`通过Vite 仍提示大 chunk 警告,但构建成功。
- DICOM preview 接口验证:`/api/projects/head-ct-demo/dicom-preview?slice=0&plane=axial&mode=soft` 返回 `200`
- STL preview 接口验证:`/api/projects/head-ct-demo/models/%E5%A4%B4%E9%83%A8.stl/preview?limit=72000` 返回 `200`
- 健康检查:`/api/health` 返回 `ok: true`
- 重新部署:已重启 `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`
- 部署后首页验证:`curl -I http://127.0.0.1:4000/` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建本次修改备份 commit提交信息为 `2026-05-19-23-47-31 优化逆向分割映射视图`
- Gitea 远端推送:执行 `git push origin main` 仍失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行拼接或保存凭据。

View File

@@ -847,3 +847,39 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
需要 Gitea 远端备份前先确认远端认证方式。优先使用安全的凭据助手、SSH remote 或用户已配置好的 token不要把账号密码写进 commit、文档、remote URL 或 shell 历史。 需要 Gitea 远端备份前先确认远端认证方式。优先使用安全的凭据助手、SSH remote 或用户已配置好的 token不要把账号密码写进 commit、文档、remote URL 或 shell 历史。
## 2026-05-19-23-47-31 逆向分割映射视图的数据来源边界
A. 具体问题
用户要求右侧二维视图展示由三维 STL 模型逆向推导的分割掩码 Label Map并与构件层级严格对应但当前后端尚未提供医学级真实体素化 Label Map 文件。
B. 产生问题原因
现有系统的真实数据能力主要是 DICOM preview、DICOM fusion volume 和 STL preview 三角面抽样,历史版本的右侧区域也已从伪二维 Mask 改为 STL 实体切面。若直接绘制任意彩色区域,会再次变成无来源 Mask。
C. 解决问题方案
右侧新建 `VoxelizationMappingView`,底层用 DICOM preview 灰度像素作为 Base Layer上层只根据 STL preview 顶点、当前 Z 切片位置和构件 `moduleStyles` 投影绘制 Overlay Label Map。每个彩色覆盖区域都来自具体 STL 构件,并复用构件颜色、透明度、显示隐藏和 `partId`
D. 后续如何避免问题
在真实体素化服务接入前,所有 Label Map 类可视化必须明确使用 STL 几何或后端生成结果作为来源;不要重新引入无数据来源的演示 Mask。后续若增加医学级体素化应保留当前 Base/Overlay UI但将 Overlay 数据源替换为后端真实 Label Map。
## 2026-05-19-23-47-31 右侧独立切片导航与左侧范围状态分离
A. 具体问题
右侧二维映射视图需要逐层浏览单张 DICOM 切片,而左侧三维融合视图使用的是 DICOM 切片范围和模型切分区间;如果共用同一状态,拖动右侧滑条会破坏左侧融合范围。
B. 产生问题原因
旧工作区已经有 `sliceStart/sliceEnd` 双端点状态,用于三维 DICOM 范围和 STL clipping plane。右侧新增 Slice Navigator 是单切片校验动作,语义不同。
C. 解决问题方案
新增独立 `mappingSlice` 状态传入 `VoxelizationMappingView`,右侧 Slice Navigator 只修改该状态;左侧 `sliceStart/sliceEnd` 继续驱动三维融合范围和模型切分,两套状态互不覆盖。
D. 后续如何避免问题
新增影像浏览控件前先判断其控制对象是“单切片位置”还是“显示范围/切割范围”。单切片校验使用独立 slice 状态,范围切割使用起止端点状态,避免不同视图之间产生隐式联动。

View File

@@ -0,0 +1,57 @@
# 需求分析-2026-05-19-23-47-31
## 开始时间
2026-05-19-23-47-31
## 原始需求摘要
用户要求对逆向工作区右侧视图做产品润色将“Mask 展示”重命名为“逆向分割映射视图”或“体素化映射视图”;明确该区域以二维 DICOM 切片为底图,叠加由三维 STL 模型逆向推导生成的二维分割掩码 Label Map覆盖层颜色、透明度、显示隐藏状态必须与中间“可视化工具栏”的“构件层级”实时联动右侧视图还需要独立 Slice Navigator支持逐层浏览当前 Z 轴 DICOM 切片及对应分割结果。
## 业务目标
- 将右侧结果区从“STL 实体切面”语义升级为“DICOM + STL 逆向体素化映射”的二维校验视图。
- 让医生或标注人员能在同一个二维视图中核对原始 CT 灰度影像和由 STL 构件层级推导出的 Label Map。
- 保证构件层级面板中的颜色、透明度和显隐状态立即反映在右侧叠加层中。
- 让右侧视图拥有独立于左侧 DICOM 范围控件的逐层切片浏览能力。
## 输入与输出
- 输入:
- 默认项目的 DICOM 切片预览接口 `/api/projects/:projectId/dicom-preview`
- STL preview 接口 `/api/projects/:projectId/models/:fileName/preview`
- 项目级 `moduleStyles`
- 用户选择的独立映射切片序号
- 输出:
- 右侧“逆向分割映射视图”
- DICOM Base Layer
- STL 逆向投影 Overlay Label Map
- 与构件层级联动的颜色、透明度、显示隐藏状态
- 独立 Slice Navigator
## 影响范围
- 主要影响 `WebSite/src/components/ReverseWorkspace.tsx`
- 可能需要补充 `WebSite/src/index.css` 的滑条样式。
- 不改变后端 API、不改变项目状态结构、不改变导出格式。
- 文档会新增本次需求、实现、测试方案,并更新经验记录。
## 关键约束
- 历史经验要求不要伪造无来源 Mask。本次覆盖层必须从 STL preview 几何数据推导,且每个覆盖区域对应具体 STL 构件与 `partId`
- DICOM 原始影像必须作为底层常驻显示。
- Overlay 可视属性必须直接读取 `moduleStyles`,避免右侧另建一套独立状态。
- Slice Navigator 必须独立于左侧 DICOM 切片范围,不应破坏左侧三维融合和模型切分流程。
- 当前工作区已有历史工程分析文档删除状态,本次提交不能混入这些删除。
## 风险点
- 当前后端尚未提供医学级真实体素化 Label Map本次前端只能基于 STL preview 三角面做浏览器侧逆向投影映射,精度取决于 STL 预览采样与配准状态。
- 高频拖动 Slice Navigator 会触发 DICOM 预览请求,需要防止旧请求覆盖新结果。
- Canvas 图层尺寸必须保持一致,否则 DICOM 和 Overlay 会错位。
- 构件数量和 STL 采样量较大时Overlay 绘制可能有性能压力。
## 默认假设
- 本次按已有确认执行,不在方案阶段等待二次确认。
- 右侧视图标题采用“逆向分割映射视图”,在界面中辅以 Label Map、Slice Navigator 等短标签。