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

@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
type ProjectStatus = 'pending' | 'completed' | 'processing';
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose';
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
type SegmentationExportScope = 'all' | 'visible';
interface ModuleStyleRecord {
@@ -35,6 +35,15 @@ interface ModelPoseRecord {
pose: ModelPoseValue;
}
interface SegmentationResultRecord {
id: string;
name: string;
createdAt: string;
segmentationScope: SegmentationExportScope;
pose: ModelPoseValue;
moduleStyles: Record<string, ModuleStyleRecord>;
}
interface UserRecord {
id: number;
name: string;
@@ -60,6 +69,7 @@ interface ProjectRecord {
isDefault?: boolean;
moduleStyles: Record<string, ModuleStyleRecord>;
modelPoses: ModelPoseRecord[];
segmentationResults: SegmentationResultRecord[];
}
interface SessionRecord {
@@ -107,6 +117,15 @@ const defaultModelPose: ModelPoseValue = {
translateZ: 0,
scale: 1,
};
const headCtBestPose: ModelPoseValue = {
rotateX: -180,
rotateY: 0,
rotateZ: 1,
translateX: -0.03,
translateY: -0.155,
translateZ: 0.005,
scale: 1,
};
interface DicomAttributes {
patientName: string;
@@ -217,6 +236,7 @@ function buildModuleStyles(
function defaultModelPoses(): ModelPoseRecord[] {
return [
{ 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 } },
];
@@ -270,6 +290,36 @@ function normalizeModelPoses(existing?: Partial<ModelPoseRecord>[]) {
return [...normalizedDefaults, ...custom];
}
function normalizeSegmentationResults(
existing: Partial<SegmentationResultRecord>[] | undefined,
stlFiles: string[],
currentModuleStyles: Record<string, ModuleStyleRecord>,
) {
if (!Array.isArray(existing)) {
return [];
}
return existing
.map((record, index): SegmentationResultRecord => {
const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles)
? record.moduleStyles
: currentModuleStyles;
return {
id: typeof record?.id === 'string' && record.id.trim()
? record.id.trim().slice(0, 80)
: `segmentation-${index}`,
name: typeof record?.name === 'string' && record.name.trim()
? record.name.trim().slice(0, 80)
: `分割结果 ${index + 1}`,
createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(),
segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible',
pose: normalizeModelPoseValue(record?.pose),
moduleStyles: buildModuleStyles(stlFiles, rawStyles),
};
})
.slice(-20);
}
function buildDefaultProject(): ProjectRecord {
const stlFiles = listFiles(modelDir, '.stl');
@@ -289,6 +339,7 @@ function buildDefaultProject(): ProjectRecord {
isDefault: true,
moduleStyles: buildModuleStyles(stlFiles),
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
@@ -308,6 +359,7 @@ function buildEmptyProject(name: string): ProjectRecord {
exportedMaskCount: 0,
moduleStyles: {},
modelPoses: defaultModelPoses(),
segmentationResults: [],
};
}
@@ -329,15 +381,21 @@ function normalizeState(state: AppState): AppState {
const customProjects = Array.isArray(state.projects)
? state.projects
.filter((project) => project.id !== defaultProject.id)
.map((project) => ({
...project,
stlFiles: Array.isArray(project.stlFiles) ? project.stlFiles : [],
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles),
modelPoses: normalizeModelPoses(project.modelPoses),
}))
.map((project) => {
const stlFiles = Array.isArray(project.stlFiles) ? project.stlFiles : [];
const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
return {
...project,
stlFiles,
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles,
modelPoses: normalizeModelPoses(project.modelPoses),
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles),
};
})
: [];
const defaultModuleStyles = buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles);
return {
...state,
@@ -346,8 +404,13 @@ function normalizeState(state: AppState): AppState {
...defaultProject,
name: savedDefaultProject?.name ?? defaultProject.name,
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
moduleStyles: defaultModuleStyles,
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
segmentationResults: normalizeSegmentationResults(
savedDefaultProject?.segmentationResults,
defaultProject.stlFiles,
defaultModuleStyles,
),
},
...customProjects,
],
@@ -1084,7 +1147,7 @@ function parseSegmentationScope(raw: unknown): SegmentationExportScope {
function parseExportTargets(raw: unknown): ProjectExportTarget[] {
const values = typeof raw === 'string' ? raw.split(',') : [];
const targets = values.filter((value): value is ProjectExportTarget => (
value === 'dicom' || value === 'segmentation' || value === 'pose'
value === 'dicom' || value === 'segmentation' || value === 'pose' || value === 'stl'
));
return [...new Set(targets)];
}
@@ -1177,6 +1240,22 @@ function createProjectExportBundle({
});
}
if (targets.includes('stl')) {
(project.stlFiles ?? []).forEach((fileName) => {
const filePath = path.join(modelDir, fileName);
if (!fs.existsSync(filePath)) {
return;
}
const stat = fs.statSync(filePath);
entries.push({
name: `${exportRoot}/STL/${fileName}`,
data: fs.readFileSync(filePath),
mtime: stat.mtimeMs / 1000,
});
});
}
if (!entries.length) {
throw new Error('未选择可导出的内容');
}
@@ -1984,6 +2063,39 @@ async function startServer() {
res.json(project);
});
app.post('/api/projects/:projectId/segmentation-results', (req, res) => {
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const rawName = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const rawStyles = req.body?.moduleStyles && typeof req.body.moduleStyles === 'object' && !Array.isArray(req.body.moduleStyles)
? {
...project.moduleStyles,
...(req.body.moduleStyles as Record<string, Partial<ModuleStyleRecord>>),
}
: project.moduleStyles;
const record: SegmentationResultRecord = {
id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
name: rawName || `分割结果 ${project.segmentationResults.length + 1}`,
createdAt: now(),
segmentationScope: parseSegmentationScope(req.body?.segmentationScope),
pose: normalizeModelPoseValue(req.body?.pose as Partial<ModelPoseValue> | undefined),
moduleStyles: buildModuleStyles(project.stlFiles, rawStyles),
};
project.segmentationResults = normalizeSegmentationResults(
[...(project.segmentationResults ?? []), record],
project.stlFiles,
project.moduleStyles,
);
writeState(state);
res.status(201).json(project);
});
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {

View File

@@ -21,8 +21,8 @@ import {
Upload
} from 'lucide-react';
import * as THREE from 'three';
import { DicomInfo, DicomPreview, ModuleStyle, Project } from '../types';
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
import { DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { api, downloadDicomArchive, downloadMask, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
@@ -52,6 +52,16 @@ interface ModelPreviewPayload {
type ModelPoseKey = keyof ModelPose;
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
{ 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: '仅导出当前显示构件' },
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 },
@@ -655,6 +665,15 @@ export default function ProjectLibrary({
const [editingProjectId, setEditingProjectId] = useState('');
const [editingName, setEditingName] = useState('');
const [actionMessage, setActionMessage] = useState('');
const [showMaskExportMenu, setShowMaskExportMenu] = useState(false);
const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
dicom: true,
segmentation: true,
pose: true,
stl: false,
});
const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible');
const [maskExporting, setMaskExporting] = useState(false);
const sliceRepeatRef = useRef<number | null>(null);
const dicomRequestRef = useRef(0);
const preloadedProjectIdsRef = useRef(new Set<string>());
@@ -697,6 +716,8 @@ export default function ProjectLibrary({
useEffect(() => {
if (selectedProject) {
preloadProjectAssets(selectedProject);
const latestResult = selectedProject.segmentationResults?.[selectedProject.segmentationResults.length - 1];
setMaskSegmentationScope(latestResult?.segmentationScope ?? 'visible');
}
}, [selectedProject?.id]);
@@ -723,6 +744,8 @@ export default function ProjectLibrary({
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true,
@@ -746,6 +769,34 @@ export default function ProjectLibrary({
});
};
const handleMaskBundleExport = async () => {
if (!selectedProject) {
return;
}
const selectedTargets = exportOptions
.filter((option) => maskExportSelection[option.id])
.map((option) => option.id);
if (!selectedTargets.length) {
setActionMessage('请至少选择一个导出内容');
return;
}
setMaskExporting(true);
setActionMessage('');
try {
await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', {
pose: latestSegmentationResult?.pose ?? modelPose,
segmentationScope: maskSegmentationScope,
});
window.setTimeout(() => setMaskExporting(false), 900);
setShowMaskExportMenu(false);
} catch (error) {
setActionMessage(error instanceof Error ? error.message : '导出失败');
setMaskExporting(false);
}
};
useEffect(() => {
setViewMode(initialViewMode);
}, [initialViewMode]);
@@ -1449,44 +1500,130 @@ export default function ProjectLibrary({
)}
{viewMode === 'mask' && (
<div className="h-full grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8">
<div className="bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center overflow-hidden">
<div className="relative w-80 h-80">
{['#3b82f6', '#22c55e', '#f59e0b'].map((color, index) => (
<div
key={color}
className="absolute inset-0 border-2"
style={{
borderColor: color,
backgroundColor: `${color}22`,
borderRadius: index === 0 ? '48% 52% 46% 54%' : '58% 42% 52% 48%',
transform: `rotate(${index * 36}deg) scale(${1 - index * 0.13})`,
}}
/>
))}
</div>
<div className="absolute left-5 top-5 text-white/50 font-mono text-[10px]">
SEGMENTATION MASK PREVIEW · NII/NII.GZ
<div className="h-full grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border border-slate-100 bg-slate-950 p-4 text-white shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="font-bold"></h3>
<p className="mt-1 text-[11px] font-bold text-white/40">
Label Map
</p>
</div>
<span className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 font-mono text-[10px] text-cyan-100">
{savedSegmentationResults.length}
</span>
</div>
{savedSegmentationResults.length ? (
<div className="grid gap-3 md:grid-cols-2">
{savedSegmentationResults.map((result, index) => (
<div key={result.id} className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-bold">{result.name}</p>
<span className="rounded bg-cyan-400/15 px-1.5 py-0.5 font-mono text-[9px] text-cyan-100">
#{index + 1}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] font-bold text-white/45">
<span>{result.segmentationScope === 'all' ? '所有类别' : '可见类别'}</span>
<span className="text-right">{new Date(result.createdAt).toLocaleString('zh-CN', { hour12: false })}</span>
<span className="font-mono">RX {result.pose.rotateX.toFixed(0)}°</span>
<span className="text-right font-mono">TZ {result.pose.translateZ.toFixed(3)}</span>
</div>
</div>
))}
</div>
) : (
<div className="flex h-[420px] items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-6 text-center text-sm font-bold text-white/35">
</div>
)}
</div>
<div className="flex flex-col gap-4">
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-100">
<h3 className="font-bold text-slate-800 mb-3"></h3>
<p className="text-sm text-slate-500 leading-6">
NIfTI maskNII.GZ
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-5">
<h3 className="mb-3 font-bold text-slate-800"></h3>
<p className="text-sm leading-6 text-slate-500">
使姿 DICOMLabel Map姿 STL
</p>
</div>
<div className="relative">
<button
onClick={() => setShowMaskExportMenu((value) => !value)}
disabled={maskExporting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white shadow-lg hover:bg-emerald-700 disabled:opacity-50"
>
<Download size={18} />
{maskExporting ? '正在导出' : '导出全部 NII.GZ'}
</button>
{showMaskExportMenu && (
<div className="absolute right-0 top-14 z-30 w-full rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="font-bold text-slate-700"></p>
<button
onClick={() => setMaskExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
>
</button>
</div>
<div className="space-y-2">
{exportOptions.map((option) => (
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
<input
type="checkbox"
checked={maskExportSelection[option.id]}
onChange={(event) => setMaskExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
className="accent-emerald-600"
/>
<span className="min-w-0 flex-1">
<span className="block">{option.label}</span>
<span className="block text-[10px] text-slate-400">{option.description}</span>
</span>
</label>
))}
</div>
{maskExportSelection.segmentation && (
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[10px] font-bold text-emerald-800"></p>
<span className="text-[9px] font-bold text-emerald-600"> labels.json</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{segmentationScopeOptions.map((option) => (
<button
key={option.id}
onClick={() => setMaskSegmentationScope(option.id)}
className={`rounded-lg px-2 py-1.5 text-left transition ${
maskSegmentationScope === option.id
? 'bg-emerald-600 text-white shadow-sm'
: 'bg-white text-emerald-700 hover:bg-emerald-100'
}`}
>
<span className="block text-[10px] font-bold">{option.label}</span>
<span className={`block text-[9px] ${maskSegmentationScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
{option.description}
</span>
</button>
))}
</div>
</div>
)}
<button
onClick={handleMaskBundleExport}
disabled={maskExporting}
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
>
</button>
</div>
)}
</div>
<button
onClick={() => downloadMask(selectedProject.id, 'nii.gz')}
className="bg-slate-900 text-white px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-black"
onClick={() => downloadMask(selectedProject.id, 'nii.gz', latestSegmentationResult?.pose ?? modelPose, maskSegmentationScope)}
className="flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-bold text-slate-700 hover:bg-slate-50"
>
<Download size={18} /> NII.GZ
</button>
<button
onClick={() => downloadMask(selectedProject.id, 'nii')}
className="bg-white text-slate-700 px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 border border-slate-200"
>
<Download size={18} /> NII
<Download size={18} /> NII.GZ
</button>
</div>
</div>

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}

View File

@@ -1,7 +1,7 @@
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SessionState, UserRecord } from '../types';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationExportScope, SessionState, UserRecord } from '../types';
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose';
export type SegmentationExportScope = 'all' | 'visible';
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
export type { SegmentationExportScope } from '../types';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
@@ -63,6 +63,19 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({ modelPoses }),
}),
saveProjectSegmentationResult: (
projectId: string,
payload: {
name?: string;
pose: ModelPose;
segmentationScope: SegmentationExportScope;
moduleStyles: Record<string, ModuleStyle>;
},
) =>
request<Project>(`/api/projects/${projectId}/segmentation-results`, {
method: 'POST',
body: JSON.stringify(payload),
}),
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
@@ -99,7 +112,7 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope } = {}) {
const params = new URLSearchParams({ target, format });
if (target !== 'dicom') {
if (target === 'segmentation' || target === 'pose') {
appendPose(params, options.pose);
}
if (target === 'segmentation') {

View File

@@ -22,6 +22,7 @@ export interface Project {
isDefault?: boolean;
moduleStyles?: Record<string, ModuleStyle>;
modelPoses?: SavedModelPose[];
segmentationResults?: SegmentationResult[];
}
export interface ModuleStyle {
@@ -47,6 +48,17 @@ export interface SavedModelPose {
pose: ModelPose;
}
export type SegmentationExportScope = 'all' | 'visible';
export interface SegmentationResult {
id: string;
name: string;
createdAt: string;
segmentationScope: SegmentationExportScope;
pose: ModelPose;
moduleStyles: Record<string, ModuleStyle>;
}
export interface MaskMapping {
className: string;
color: string;