2026-05-08-03-57-51 接入DICOM SEG双切面展示

This commit is contained in:
2026-05-08 04:19:24 +08:00
parent 946c0f4ef3
commit fe4b90abcd
6 changed files with 626 additions and 25 deletions

View File

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