2026-05-19-23-47-31 优化逆向分割映射视图
This commit is contained in:
@@ -5,12 +5,14 @@ import {
|
||||
Download,
|
||||
Rotate3d,
|
||||
AlertCircle,
|
||||
Play,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Layers,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
|
||||
import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
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 }) {
|
||||
const [sliceStart, setSliceStart] = useState(0);
|
||||
const [sliceEnd, setSliceEnd] = useState(49);
|
||||
const [mappingSlice, setMappingSlice] = useState(0);
|
||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||
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);
|
||||
setSliceStart(0);
|
||||
setSliceEnd(maxIndex);
|
||||
setMappingSlice(maxIndex);
|
||||
setModelPose(defaultModelPose);
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(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 safeSliceStart = clamp(sliceStart, 0, maxSlice);
|
||||
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
|
||||
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
|
||||
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
|
||||
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="px-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Play size={18} className="text-blue-500" />
|
||||
Mask 展示
|
||||
<Layers size={18} className="text-cyan-500" />
|
||||
逆向分割映射视图
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -1439,15 +1832,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CutSectionPreview
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
volume={fusionVolume}
|
||||
modelPose={modelPose}
|
||||
moduleStyles={moduleStyles}
|
||||
detailLimit={selectedDisplay.limit}
|
||||
cutEnabled={cutEnabled}
|
||||
cutStart={displayStart}
|
||||
cutEnd={displayEnd}
|
||||
slice={safeMappingSlice}
|
||||
totalSlices={project?.dicomCount ?? 0}
|
||||
onSliceChange={setMappingSlice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,3 +59,60 @@
|
||||
.dicom-range-input:active::-moz-range-thumb {
|
||||
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;
|
||||
}
|
||||
|
||||
57
工程分析/实现方案-2026-05-19-23-47-31.md
Normal file
57
工程分析/实现方案-2026-05-19-23-47-31.md
Normal 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` 和首页响应。
|
||||
63
工程分析/测试方案-2026-05-19-23-47-31.md
Normal file
63
工程分析/测试方案-2026-05-19-23-47-31.md
Normal 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 Navigator,DICOM 切片和 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` 无法读取用户名;未在命令行拼接或保存凭据。
|
||||
36
工程分析/经验记录.md
36
工程分析/经验记录.md
@@ -847,3 +847,39 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
需要 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 状态,范围切割使用起止端点状态,避免不同视图之间产生隐式联动。
|
||||
|
||||
57
工程分析/需求分析-2026-05-19-23-47-31.md
Normal file
57
工程分析/需求分析-2026-05-19-23-47-31.md
Normal 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 等短标签。
|
||||
Reference in New Issue
Block a user