2026-05-20-03-19-25 完善分割结果保存与STL导出
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 格式分割 mask。NII.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">
|
||||
默认使用最新保存的分割结果位姿;可选择导出 DICOM、Label 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user