2026-05-20-22-35-42 导入资产与分割导出优化
This commit is contained in:
@@ -22,10 +22,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
|
||||
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api';
|
||||
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
|
||||
import {
|
||||
FusionThreeView,
|
||||
VoxelizationMappingView,
|
||||
clearCachedProjectAssets,
|
||||
getCachedDicomFusionVolume,
|
||||
getCachedDicomPreview,
|
||||
getCachedModelPreview,
|
||||
@@ -63,14 +64,18 @@ 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: '原始三维构件' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
];
|
||||
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
|
||||
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
|
||||
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
|
||||
];
|
||||
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
||||
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
||||
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
|
||||
];
|
||||
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
|
||||
{ id: 'standard', label: '标准', limit: 16000 },
|
||||
{ id: 'fine', label: '精细', limit: 36000 },
|
||||
@@ -117,6 +122,17 @@ function formatPoseCompactValue(value: number, digits = 2) {
|
||||
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
|
||||
}
|
||||
|
||||
async function fileToBase64(file: File) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
function drawFallbackModelPreview(
|
||||
canvas: HTMLCanvasElement,
|
||||
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
|
||||
@@ -676,13 +692,17 @@ export default function ProjectLibrary({
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
const [showMaskExportMenu, setShowMaskExportMenu] = useState(false);
|
||||
const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||||
dicom: true,
|
||||
dicom: false,
|
||||
segmentation: true,
|
||||
pose: true,
|
||||
stl: false,
|
||||
});
|
||||
const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible');
|
||||
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
const [maskExporting, setMaskExporting] = useState(false);
|
||||
const [assetImporting, setAssetImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
|
||||
const sliceRepeatRef = useRef<number | null>(null);
|
||||
const dicomRequestRef = useRef(0);
|
||||
const preloadedProjectIdsRef = useRef(new Set<string>());
|
||||
@@ -804,6 +824,7 @@ export default function ProjectLibrary({
|
||||
await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', {
|
||||
pose: latestSegmentationResult?.pose ?? modelPose,
|
||||
segmentationScope: maskSegmentationScope,
|
||||
segmentationExportMode: maskSegmentationExportMode,
|
||||
});
|
||||
window.setTimeout(() => setMaskExporting(false), 900);
|
||||
setShowMaskExportMenu(false);
|
||||
@@ -813,6 +834,66 @@ export default function ProjectLibrary({
|
||||
}
|
||||
};
|
||||
|
||||
const triggerProjectAssetImport = () => {
|
||||
if (!selectedProject || viewMode === 'mask' || assetImporting) {
|
||||
return;
|
||||
}
|
||||
const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom';
|
||||
const input = importInputRef.current;
|
||||
if (!input) {
|
||||
setActionMessage('导入控件尚未就绪,请稍后重试');
|
||||
return;
|
||||
}
|
||||
importKindRef.current = kind;
|
||||
input.value = '';
|
||||
input.accept = kind === 'dicom' ? '.dcm,.dicom,application/dicom' : '.stl';
|
||||
input.multiple = true;
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleProjectAssetImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = '';
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = importKindRef.current;
|
||||
setAssetImporting(true);
|
||||
setActionMessage(kind === 'dicom' ? '正在导入 DICOM 影像...' : '正在导入 STL 模型...');
|
||||
try {
|
||||
const payload = await Promise.all(files.map(async (file) => ({
|
||||
name: file.name,
|
||||
data: await fileToBase64(file),
|
||||
})));
|
||||
const updated = await api.importProjectAssets(selectedProject.id, kind, payload);
|
||||
clearCachedProjectAssets(updated.id);
|
||||
preloadedProjectIdsRef.current.delete(updated.id);
|
||||
setSelectedProject(updated);
|
||||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||||
const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1];
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(updated.stlFiles ?? []).forEach((fileName, index) => {
|
||||
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
|
||||
});
|
||||
setModuleStyles(nextStyles);
|
||||
setModelPose(latestResult?.pose ?? defaultModelPose);
|
||||
setResultPose(latestResult?.pose ?? defaultModelPose);
|
||||
setSliceIndex(0);
|
||||
setDicomPreview(null);
|
||||
setDicomError('');
|
||||
setResultFusionVolume(null);
|
||||
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : '项目资产导入失败');
|
||||
} finally {
|
||||
setAssetImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setViewMode(initialViewMode);
|
||||
}, [initialViewMode]);
|
||||
@@ -1204,6 +1285,12 @@ export default function ProjectLibrary({
|
||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
{selectedProject ? (
|
||||
<>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleProjectAssetImport}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
{tabs.map((tab) => (
|
||||
@@ -1226,8 +1313,12 @@ export default function ProjectLibrary({
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
{viewMode !== 'mask' && (
|
||||
<button className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all">
|
||||
<Upload size={18} /> 导入
|
||||
<button
|
||||
onClick={triggerProjectAssetImport}
|
||||
disabled={assetImporting}
|
||||
className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Upload size={18} /> {assetImporting ? '导入中' : '导入'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1292,7 +1383,9 @@ export default function ProjectLibrary({
|
||||
{dicomPreview ? (
|
||||
<DicomCanvas preview={dicomPreview} rotation={rotation} />
|
||||
) : (
|
||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
|
||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">
|
||||
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
|
||||
</p>
|
||||
)}
|
||||
{isSliceChanging && dicomPreview && (
|
||||
<span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm">
|
||||
@@ -1301,8 +1394,8 @@ export default function ProjectLibrary({
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
||||
<span>第 {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
||||
<span>第 {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Vertical Progress Bar */}
|
||||
@@ -1389,6 +1482,13 @@ export default function ProjectLibrary({
|
||||
pose={modelPose}
|
||||
onPoseChange={setModelPose}
|
||||
/>
|
||||
{!stlFiles.length && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<p className="rounded-2xl border border-slate-200 bg-white/85 px-5 py-3 text-xs font-bold text-slate-500 shadow-sm">
|
||||
请导入STL模型
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
|
||||
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
|
||||
</div>
|
||||
@@ -1724,6 +1824,27 @@ export default function ProjectLibrary({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationExportModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationExportMode(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationExportMode === option.id
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user