2026-05-20-03-19-25 完善分割结果保存与STL导出

This commit is contained in:
2026-05-20 03:33:21 +08:00
parent b9c0f17313
commit 25f34d1eef
10 changed files with 667 additions and 78 deletions

View File

@@ -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}