2026-05-20-03-19-25 完善分割结果保存与STL导出
This commit is contained in:
@@ -72,9 +72,19 @@ const defaultModelPose: ModelPose = {
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
};
|
||||
const headCtBestPose: ModelPose = {
|
||||
rotateX: -180,
|
||||
rotateY: 0,
|
||||
rotateZ: 1,
|
||||
translateX: -0.03,
|
||||
translateY: -0.155,
|
||||
translateZ: 0.005,
|
||||
scale: 1,
|
||||
};
|
||||
|
||||
const defaultSavedPoses: SavedModelPose[] = [
|
||||
{ id: 'default', name: '默认', pose: defaultModelPose },
|
||||
{ id: 'best', name: '最佳位姿', pose: headCtBestPose },
|
||||
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
||||
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
||||
];
|
||||
@@ -82,6 +92,7 @@ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description
|
||||
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
|
||||
];
|
||||
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
|
||||
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
|
||||
@@ -1084,6 +1095,15 @@ interface OverlayStats {
|
||||
activeModules: number;
|
||||
filledPixels: number;
|
||||
segmentCount: number;
|
||||
modules: Array<{
|
||||
fileName: string;
|
||||
name: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
partId: number;
|
||||
segmentCount: number;
|
||||
filledPixels: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
|
||||
@@ -1593,13 +1613,13 @@ function drawVoxelOverlayLayer(
|
||||
canvas.height = fovCanvas.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { activeModules: 0, filledPixels: 0, segmentCount: 0 };
|
||||
return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] };
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, fovCanvas.width, fovCanvas.height);
|
||||
const metrics = getModelSceneMetrics(files, previews, preview, totalSlices);
|
||||
if (!metrics) {
|
||||
return { activeModules: 0, filledPixels: 0, segmentCount: 0 };
|
||||
return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] };
|
||||
}
|
||||
|
||||
const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0));
|
||||
@@ -1613,6 +1633,7 @@ function drawVoxelOverlayLayer(
|
||||
let activeModules = 0;
|
||||
let filledPixels = 0;
|
||||
let segmentCount = 0;
|
||||
const modules: OverlayStats['modules'] = [];
|
||||
|
||||
files.forEach((fileName, index) => {
|
||||
const payload = previews[fileName];
|
||||
@@ -1662,14 +1683,23 @@ function drawVoxelOverlayLayer(
|
||||
}
|
||||
|
||||
const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity);
|
||||
if (segments.length > 0) {
|
||||
if (segments.length > 0 || modulePixels > 0) {
|
||||
activeModules += 1;
|
||||
modules.push({
|
||||
fileName,
|
||||
name: fileName.replace(/\.stl$/i, ''),
|
||||
color: style.color,
|
||||
opacity: style.opacity,
|
||||
partId: style.partId,
|
||||
segmentCount: segments.length,
|
||||
filledPixels: modulePixels,
|
||||
});
|
||||
}
|
||||
filledPixels += modulePixels;
|
||||
segmentCount += segments.length;
|
||||
});
|
||||
|
||||
return { activeModules, filledPixels, segmentCount };
|
||||
return { activeModules, filledPixels, segmentCount, modules };
|
||||
}
|
||||
|
||||
function VoxelizationMappingView({
|
||||
@@ -1695,7 +1725,7 @@ function VoxelizationMappingView({
|
||||
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
|
||||
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
|
||||
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
|
||||
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0 });
|
||||
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||||
const maxSlice = Math.max(totalSlices - 1, 0);
|
||||
const safeSlice = clamp(slice, 0, maxSlice);
|
||||
const stlFiles = project?.stlFiles ?? [];
|
||||
@@ -1842,22 +1872,24 @@ function VoxelizationMappingView({
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||||
</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 className="max-h-20 overflow-auto pr-1">
|
||||
{overlayStats.modules.length ? (
|
||||
<div className="grid grid-cols-2 gap-1.5 xl:grid-cols-3">
|
||||
{overlayStats.modules.map((item) => (
|
||||
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-[8px] font-bold text-white/70">
|
||||
<span className="h-2 w-2 rounded-sm border border-white/20" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||||
<span className="min-w-0 truncate">{item.name}</span>
|
||||
<span className="font-mono text-cyan-100">ID {item.partId}</span>
|
||||
<span className="col-start-2 font-mono text-white/35">{item.segmentCount} 边</span>
|
||||
<span className="font-mono text-white/35">{item.filledPixels} px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-[9px] font-bold text-white/35">
|
||||
当前切片暂无可见构件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1928,11 +1960,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
dicom: true,
|
||||
segmentation: true,
|
||||
pose: true,
|
||||
stl: false,
|
||||
});
|
||||
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
||||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||
@@ -1973,6 +2007,27 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSegmentationResult = async () => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFusionError('');
|
||||
setSaveStatus('');
|
||||
try {
|
||||
const updated = await api.saveProjectSegmentationResult(project.id, {
|
||||
name: `分割结果 ${new Date().toLocaleString('zh-CN', { hour12: false })}`,
|
||||
pose: modelPose,
|
||||
segmentationScope: segmentationExportScope,
|
||||
moduleStyles,
|
||||
});
|
||||
setProject(updated);
|
||||
setSaveStatus('已保存至项目库的分割结果区域');
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '保存至项目库失败');
|
||||
}
|
||||
};
|
||||
|
||||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||
visible: fallback?.visible ?? true,
|
||||
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
||||
@@ -2021,14 +2076,19 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
setSliceStart(0);
|
||||
setSliceEnd(maxIndex);
|
||||
setMappingSlice(maxIndex);
|
||||
setModelPose(defaultModelPose);
|
||||
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
|
||||
const preferredPose = nextPoses.find((pose) => pose.id === 'best')
|
||||
?? nextPoses.find((pose) => pose.name.includes('最佳'))
|
||||
?? nextPoses.find((pose) => pose.name === '位姿2')
|
||||
?? nextPoses[0];
|
||||
setModelPose(preferredPose?.pose ?? headCtBestPose);
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||||
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
|
||||
});
|
||||
setModuleStyles(nextStyles);
|
||||
setSavedPoses(item.modelPoses?.length ? item.modelPoses : defaultSavedPoses);
|
||||
setSelectedPoseId('default');
|
||||
setSavedPoses(nextPoses);
|
||||
setSelectedPoseId(preferredPose?.id ?? 'best');
|
||||
}).catch(() => {
|
||||
setProject(null);
|
||||
setFusionVolume(null);
|
||||
@@ -2315,7 +2375,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="font-bold text-slate-700">导出内容</p>
|
||||
<button
|
||||
onClick={() => setExportSelection({ dicom: true, segmentation: true, pose: true })}
|
||||
onClick={() => setExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
全选
|
||||
@@ -2377,8 +2437,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
|
||||
<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-6 min-h-0 flex flex-col gap-4">
|
||||
<div className="px-2 flex items-center justify-between shrink-0">
|
||||
<div className="lg:col-span-4 min-h-0 flex flex-col gap-4">
|
||||
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Rotate3d size={18} className="text-blue-500" />
|
||||
影像与模型融合视角
|
||||
@@ -2462,7 +2522,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3 min-h-0 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="lg:col-span-4 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" />
|
||||
@@ -2746,13 +2806,21 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="lg:col-span-4 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">
|
||||
<Layers size={18} className="text-cyan-500" />
|
||||
逆向分割映射视图
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleSaveSegmentationResult}
|
||||
disabled={!project}
|
||||
className="bg-cyan-50 hover:bg-cyan-100 text-cyan-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-cyan-100 flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Save size={12} />
|
||||
保存至项目库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('nii')}
|
||||
disabled={exporting}
|
||||
@@ -2771,6 +2839,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{saveStatus && (
|
||||
<div className="rounded-xl border border-cyan-100 bg-cyan-50 px-3 py-2 text-[10px] font-bold text-cyan-700">
|
||||
{saveStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
|
||||
Reference in New Issue
Block a user