2026-05-08-03-57-51 接入DICOM SEG双切面展示
This commit is contained in:
@@ -147,6 +147,14 @@ type StlModel = {
|
||||
triangleCount: number;
|
||||
};
|
||||
|
||||
type SegmentationMask = {
|
||||
segId: string;
|
||||
name: string;
|
||||
frameCount: number;
|
||||
segmentCount: number;
|
||||
labels?: { value: number; label: string }[];
|
||||
};
|
||||
|
||||
type StoredDeformationJob = {
|
||||
job: BackendJob;
|
||||
progress: number;
|
||||
@@ -396,6 +404,8 @@ export default function App() {
|
||||
const [viewerError, setViewerError] = useState('');
|
||||
const [stlModel, setStlModel] = useState<StlModel | null>(null);
|
||||
const [isUploadingStl, setIsUploadingStl] = useState(false);
|
||||
const [segmentationMask, setSegmentationMask] = useState<SegmentationMask | null>(null);
|
||||
const [isUploadingSegmentation, setIsUploadingSegmentation] = useState(false);
|
||||
const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false);
|
||||
const [modelClipStart, setModelClipStart] = useState(0);
|
||||
const [modelClipEnd, setModelClipEnd] = useState(0);
|
||||
@@ -406,6 +416,7 @@ export default function App() {
|
||||
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const segmentationUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// --- Simulation State (Workspace) ---
|
||||
const [cervicalRotation, setCervicalRotation] = useState(14.5);
|
||||
@@ -953,6 +964,7 @@ export default function App() {
|
||||
setIsModelSlicingEnabled(false);
|
||||
setModelClipStart(0);
|
||||
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
|
||||
setSegmentationMask(null);
|
||||
setModelStartPreview(null);
|
||||
setModelEndPreview(null);
|
||||
setModelMaskError('');
|
||||
@@ -964,6 +976,7 @@ export default function App() {
|
||||
setViewerError('');
|
||||
setIsViewerLoading(false);
|
||||
setIsModelSlicingEnabled(false);
|
||||
setSegmentationMask(null);
|
||||
setModelStartPreview(null);
|
||||
setModelEndPreview(null);
|
||||
setModelMaskError('');
|
||||
@@ -1001,6 +1014,21 @@ export default function App() {
|
||||
return () => controller.abort();
|
||||
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryViewerItem) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_BASE}/api/segmentation/list?id=${encodeURIComponent(libraryViewerItem.id)}`, { signal: controller.signal })
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Segmentation Mask 读取失败');
|
||||
setSegmentationMask((data.items?.[0] || null) as SegmentationMask | null);
|
||||
})
|
||||
.catch(error => {
|
||||
if ((error as Error).name !== 'AbortError') setSegmentationMask(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [libraryViewerItem?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryViewerItem || !viewerPreview?.count) return;
|
||||
setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current)));
|
||||
@@ -1012,10 +1040,17 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) return;
|
||||
if (!segmentationMask) {
|
||||
setModelStartPreview(null);
|
||||
setModelEndPreview(null);
|
||||
setModelMaskError('请上传或关联 DICOM Segmentation Mask');
|
||||
setIsModelMaskLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const makeUrl = (index: number) => (
|
||||
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${index}&window=${encodeURIComponent(viewerWindow)}&modelId=${encodeURIComponent(stlModel.modelId)}&maskOnly=1`
|
||||
`${API_BASE}/api/segmentation/preview?id=${encodeURIComponent(libraryViewerItem.id)}&segId=${encodeURIComponent(segmentationMask.segId)}&plane=${encodeURIComponent(viewerPlane)}&index=${index}`
|
||||
);
|
||||
|
||||
setIsModelMaskLoading(true);
|
||||
@@ -1044,12 +1079,16 @@ export default function App() {
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, viewerPlane, viewerWindow, clampedModelStart, clampedModelEnd]);
|
||||
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, segmentationMask?.segId, viewerPlane, clampedModelStart, clampedModelEnd]);
|
||||
|
||||
const uploadStlModel = () => {
|
||||
stlUploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const uploadSegmentationMask = () => {
|
||||
segmentationUploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleStlSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
@@ -1076,6 +1115,33 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSegmentationSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file || !libraryViewerItem) return;
|
||||
setIsUploadingSegmentation(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/segmentation/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/dicom',
|
||||
'x-file-name': encodeURIComponent(file.name),
|
||||
'x-library-id': encodeURIComponent(libraryViewerItem.id),
|
||||
},
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Segmentation Mask 上传失败');
|
||||
setSegmentationMask(data);
|
||||
setModelMaskError('');
|
||||
showToast(`已载入 Segmentation Mask:${data.segmentCount || 0} 个标签`);
|
||||
} catch (error) {
|
||||
showToast((error as Error).message);
|
||||
} finally {
|
||||
setIsUploadingSegmentation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = (userId: string, newPass: string) => {
|
||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||
setPwChangeInput('');
|
||||
@@ -2085,6 +2151,13 @@ export default function App() {
|
||||
className="hidden"
|
||||
onChange={handleStlSelected}
|
||||
/>
|
||||
<input
|
||||
ref={segmentationUploadInputRef}
|
||||
type="file"
|
||||
accept=".dcm,.dicom,application/dicom"
|
||||
className="hidden"
|
||||
onChange={handleSegmentationSelected}
|
||||
/>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
|
||||
<div className="space-y-5">
|
||||
@@ -2148,7 +2221,7 @@ export default function App() {
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">模型切分</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 mt-1 truncate">
|
||||
{stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 后启用真实 mask'}
|
||||
{stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 设定切分范围'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -2163,13 +2236,28 @@ export default function App() {
|
||||
{isModelSlicingEnabled ? '已启用' : '启用'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={uploadStlModel}
|
||||
disabled={isUploadingStl}
|
||||
className="w-full py-2.5 rounded-xl bg-slate-900 text-white text-xs font-black hover:bg-blue-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploadingStl ? '上传解析中...' : '上传 STL 模型'}
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={uploadStlModel}
|
||||
disabled={isUploadingStl}
|
||||
className="py-2.5 rounded-xl bg-slate-900 text-white text-xs font-black hover:bg-blue-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploadingStl ? '解析中...' : '上传 STL'}
|
||||
</button>
|
||||
<button
|
||||
onClick={uploadSegmentationMask}
|
||||
disabled={isUploadingSegmentation}
|
||||
className="py-2.5 rounded-xl bg-emerald-600 text-white text-xs font-black hover:bg-emerald-700 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploadingSegmentation ? '读取中...' : '上传 DICOM SEG'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 border border-slate-100 px-3 py-2">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Segmentation Mask</p>
|
||||
<p className="text-[10px] font-bold text-slate-500 mt-1 truncate">
|
||||
{segmentationMask ? `${segmentationMask.name} · ${segmentationMask.segmentCount} 标签` : '未绑定 DICOM SEG'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isModelSlicingEnabled && (
|
||||
<div className="space-y-3">
|
||||
@@ -2206,7 +2294,7 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-slate-400">
|
||||
两个端点可交叉;显示时分别按起点帧和终点帧切 STL。
|
||||
两个端点可交叉;右侧按 DICOM SEG 渲染上下切面。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -2235,7 +2323,7 @@ export default function App() {
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Mask 展示</p>
|
||||
<p className="text-[10px] font-bold text-white/55 mt-1">
|
||||
{VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label} · {VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label}
|
||||
{segmentationMask ? `${VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label} · ${segmentationMask.name}` : '未绑定 DICOM SEG'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1.5 rounded-full bg-white/10 text-[10px] font-mono font-black text-white/70">
|
||||
@@ -2244,8 +2332,8 @@ export default function App() {
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 grid grid-cols-1 xl:grid-cols-2 gap-0">
|
||||
{[
|
||||
{ label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' },
|
||||
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
|
||||
{ label: '上侧切面', preview: modelStartPreview, color: 'text-blue-300' },
|
||||
{ label: '下侧切面', preview: modelEndPreview, color: 'text-orange-300' },
|
||||
].map(item => (
|
||||
<div key={item.label} className="relative min-h-[300px] flex items-center justify-center border-slate-800 xl:border-l first:border-l-0">
|
||||
{item.preview?.imageUrl && !modelMaskError ? (
|
||||
@@ -2253,7 +2341,7 @@ export default function App() {
|
||||
) : (
|
||||
<div className="text-center text-white/35">
|
||||
<ImageIcon size={38} className="mx-auto mb-3" />
|
||||
<p className="text-xs font-bold">{modelMaskError || '等待 STL mask'}</p>
|
||||
<p className="text-xs font-bold">{modelMaskError || '等待 Segmentation Mask'}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
||||
@@ -2264,7 +2352,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="absolute right-4 bottom-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
||||
<p className="text-[10px] font-black text-white/70">
|
||||
MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'}
|
||||
MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无分割区域'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user