2026-05-08-03-57-51 接入DICOM SEG双切面展示
This commit is contained in:
@@ -147,6 +147,14 @@ type StlModel = {
|
|||||||
triangleCount: number;
|
triangleCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SegmentationMask = {
|
||||||
|
segId: string;
|
||||||
|
name: string;
|
||||||
|
frameCount: number;
|
||||||
|
segmentCount: number;
|
||||||
|
labels?: { value: number; label: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
type StoredDeformationJob = {
|
type StoredDeformationJob = {
|
||||||
job: BackendJob;
|
job: BackendJob;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -396,6 +404,8 @@ export default function App() {
|
|||||||
const [viewerError, setViewerError] = useState('');
|
const [viewerError, setViewerError] = useState('');
|
||||||
const [stlModel, setStlModel] = useState<StlModel | null>(null);
|
const [stlModel, setStlModel] = useState<StlModel | null>(null);
|
||||||
const [isUploadingStl, setIsUploadingStl] = useState(false);
|
const [isUploadingStl, setIsUploadingStl] = useState(false);
|
||||||
|
const [segmentationMask, setSegmentationMask] = useState<SegmentationMask | null>(null);
|
||||||
|
const [isUploadingSegmentation, setIsUploadingSegmentation] = useState(false);
|
||||||
const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false);
|
const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false);
|
||||||
const [modelClipStart, setModelClipStart] = useState(0);
|
const [modelClipStart, setModelClipStart] = useState(0);
|
||||||
const [modelClipEnd, setModelClipEnd] = useState(0);
|
const [modelClipEnd, setModelClipEnd] = useState(0);
|
||||||
@@ -406,6 +416,7 @@ export default function App() {
|
|||||||
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const segmentationUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// --- Simulation State (Workspace) ---
|
// --- Simulation State (Workspace) ---
|
||||||
const [cervicalRotation, setCervicalRotation] = useState(14.5);
|
const [cervicalRotation, setCervicalRotation] = useState(14.5);
|
||||||
@@ -953,6 +964,7 @@ export default function App() {
|
|||||||
setIsModelSlicingEnabled(false);
|
setIsModelSlicingEnabled(false);
|
||||||
setModelClipStart(0);
|
setModelClipStart(0);
|
||||||
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
|
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
|
||||||
|
setSegmentationMask(null);
|
||||||
setModelStartPreview(null);
|
setModelStartPreview(null);
|
||||||
setModelEndPreview(null);
|
setModelEndPreview(null);
|
||||||
setModelMaskError('');
|
setModelMaskError('');
|
||||||
@@ -964,6 +976,7 @@ export default function App() {
|
|||||||
setViewerError('');
|
setViewerError('');
|
||||||
setIsViewerLoading(false);
|
setIsViewerLoading(false);
|
||||||
setIsModelSlicingEnabled(false);
|
setIsModelSlicingEnabled(false);
|
||||||
|
setSegmentationMask(null);
|
||||||
setModelStartPreview(null);
|
setModelStartPreview(null);
|
||||||
setModelEndPreview(null);
|
setModelEndPreview(null);
|
||||||
setModelMaskError('');
|
setModelMaskError('');
|
||||||
@@ -1001,6 +1014,21 @@ export default function App() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!libraryViewerItem || !viewerPreview?.count) return;
|
if (!libraryViewerItem || !viewerPreview?.count) return;
|
||||||
setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current)));
|
setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current)));
|
||||||
@@ -1012,10 +1040,17 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) return;
|
if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) return;
|
||||||
|
if (!segmentationMask) {
|
||||||
|
setModelStartPreview(null);
|
||||||
|
setModelEndPreview(null);
|
||||||
|
setModelMaskError('请上传或关联 DICOM Segmentation Mask');
|
||||||
|
setIsModelMaskLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const makeUrl = (index: number) => (
|
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);
|
setIsModelMaskLoading(true);
|
||||||
@@ -1044,12 +1079,16 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, viewerPlane, viewerWindow, clampedModelStart, clampedModelEnd]);
|
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, segmentationMask?.segId, viewerPlane, clampedModelStart, clampedModelEnd]);
|
||||||
|
|
||||||
const uploadStlModel = () => {
|
const uploadStlModel = () => {
|
||||||
stlUploadInputRef.current?.click();
|
stlUploadInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadSegmentationMask = () => {
|
||||||
|
segmentationUploadInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleStlSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleStlSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = '';
|
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) => {
|
const changePassword = (userId: string, newPass: string) => {
|
||||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||||
setPwChangeInput('');
|
setPwChangeInput('');
|
||||||
@@ -2085,6 +2151,13 @@ export default function App() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleStlSelected}
|
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="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -2148,7 +2221,7 @@ export default function App() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">模型切分</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -2163,13 +2236,28 @@ export default function App() {
|
|||||||
{isModelSlicingEnabled ? '已启用' : '启用'}
|
{isModelSlicingEnabled ? '已启用' : '启用'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="grid grid-cols-2 gap-2">
|
||||||
onClick={uploadStlModel}
|
<button
|
||||||
disabled={isUploadingStl}
|
onClick={uploadStlModel}
|
||||||
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"
|
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>
|
{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 && (
|
{isModelSlicingEnabled && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -2206,7 +2294,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] font-bold text-slate-400">
|
<p className="text-[10px] font-bold text-slate-400">
|
||||||
两个端点可交叉;显示时分别按起点帧和终点帧切 STL。
|
两个端点可交叉;右侧按 DICOM SEG 渲染上下切面。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2235,7 +2323,7 @@ export default function App() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Mask 展示</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-3 py-1.5 rounded-full bg-white/10 text-[10px] font-mono font-black text-white/70">
|
<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>
|
||||||
<div className="min-h-0 flex-1 grid grid-cols-1 xl:grid-cols-2 gap-0">
|
<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: modelStartPreview, color: 'text-blue-300' },
|
||||||
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
|
{ label: '下侧切面', preview: modelEndPreview, color: 'text-orange-300' },
|
||||||
].map(item => (
|
].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">
|
<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 ? (
|
{item.preview?.imageUrl && !modelMaskError ? (
|
||||||
@@ -2253,7 +2341,7 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center text-white/35">
|
<div className="text-center text-white/35">
|
||||||
<ImageIcon size={38} className="mx-auto mb-3" />
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
<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>
|
||||||
<div className="absolute right-4 bottom-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
348
web_backend.py
348
web_backend.py
@@ -55,6 +55,19 @@ PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
|
|||||||
MODEL_DIR = LIBRARY_DIR / "_stl_models"
|
MODEL_DIR = LIBRARY_DIR / "_stl_models"
|
||||||
STL_MODEL_CACHE = {}
|
STL_MODEL_CACHE = {}
|
||||||
STL_MODEL_CACHE_LOCK = threading.Lock()
|
STL_MODEL_CACHE_LOCK = threading.Lock()
|
||||||
|
SEGMENTATION_CACHE = {}
|
||||||
|
SEGMENTATION_CACHE_LOCK = threading.Lock()
|
||||||
|
SEGMENTATION_CACHE_LIMIT = 4
|
||||||
|
SEGMENTATION_COLORS = [
|
||||||
|
(255, 112, 32),
|
||||||
|
(34, 211, 238),
|
||||||
|
(168, 85, 247),
|
||||||
|
(74, 222, 128),
|
||||||
|
(250, 204, 21),
|
||||||
|
(244, 114, 182),
|
||||||
|
(96, 165, 250),
|
||||||
|
(251, 146, 60),
|
||||||
|
]
|
||||||
VIEWER_WINDOWS = {
|
VIEWER_WINDOWS = {
|
||||||
"default": {"label": "默认", "low": -500, "high": 1200},
|
"default": {"label": "默认", "low": -500, "high": 1200},
|
||||||
"bone": {"label": "骨窗", "low": -500, "high": 1800},
|
"bone": {"label": "骨窗", "low": -500, "high": 1800},
|
||||||
@@ -83,6 +96,15 @@ def safe_model_filename(name):
|
|||||||
return name if name.lower().endswith(".stl") else f"{name}.stl"
|
return name if name.lower().endswith(".stl") else f"{name}.stl"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_segmentation_filename(name):
|
||||||
|
name = safe_filename(name or "segmentation.dcm")
|
||||||
|
path = Path(name)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in {".dcm", ".dicom"}:
|
||||||
|
return f"{path.stem}{suffix}"
|
||||||
|
return f"{name}.dcm"
|
||||||
|
|
||||||
|
|
||||||
def normalized_username(username):
|
def normalized_username(username):
|
||||||
username = str(username or "").strip()
|
username = str(username or "").strip()
|
||||||
return username or "anonymous"
|
return username or "anonymous"
|
||||||
@@ -343,6 +365,244 @@ def load_stl_model(model_id):
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def segmentation_root_for_item(item):
|
||||||
|
return Path(item["dicomPath"]).resolve().parent / "segmentations"
|
||||||
|
|
||||||
|
|
||||||
|
def segmentation_file_for_id(item, segmentation_id):
|
||||||
|
segmentation_id = safe_filename(segmentation_id)
|
||||||
|
if not segmentation_id:
|
||||||
|
return None
|
||||||
|
root = segmentation_root_for_item(item)
|
||||||
|
matches = sorted(root.glob(f"{segmentation_id}_*.dcm")) + sorted(root.glob(f"{segmentation_id}_*.dicom"))
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
|
||||||
|
def segmentation_meta_path(segmentation_path):
|
||||||
|
return segmentation_path.with_suffix(segmentation_path.suffix + ".json")
|
||||||
|
|
||||||
|
|
||||||
|
def segmentation_signature(segmentation_path):
|
||||||
|
segmentation_path = Path(segmentation_path).resolve()
|
||||||
|
return (
|
||||||
|
str(segmentation_path),
|
||||||
|
segmentation_path.stat().st_size,
|
||||||
|
segmentation_path.stat().st_mtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def segment_labels_from_dataset(ds):
|
||||||
|
labels = {}
|
||||||
|
for segment in getattr(ds, "SegmentSequence", []) or []:
|
||||||
|
try:
|
||||||
|
number = int(getattr(segment, "SegmentNumber", 0))
|
||||||
|
except Exception:
|
||||||
|
number = 0
|
||||||
|
if number > 0:
|
||||||
|
labels[number] = str(getattr(segment, "SegmentLabel", f"Segment {number}"))
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def frame_segment_number(ds, frame_index):
|
||||||
|
per_frame = getattr(ds, "PerFrameFunctionalGroupsSequence", None)
|
||||||
|
if per_frame and frame_index < len(per_frame):
|
||||||
|
segment_sequence = getattr(per_frame[frame_index], "SegmentIdentificationSequence", None)
|
||||||
|
if segment_sequence:
|
||||||
|
try:
|
||||||
|
return int(getattr(segment_sequence[0], "ReferencedSegmentNumber", 1))
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def frame_position_patient(ds, frame_index):
|
||||||
|
per_frame = getattr(ds, "PerFrameFunctionalGroupsSequence", None)
|
||||||
|
if per_frame and frame_index < len(per_frame):
|
||||||
|
plane_position = getattr(per_frame[frame_index], "PlanePositionSequence", None)
|
||||||
|
if plane_position and hasattr(plane_position[0], "ImagePositionPatient"):
|
||||||
|
return np.asarray(plane_position[0].ImagePositionPatient, dtype=np.float64)
|
||||||
|
if hasattr(ds, "ImagePositionPatient"):
|
||||||
|
return np.asarray(ds.ImagePositionPatient, dtype=np.float64)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_segmentation_frames(pixel_array):
|
||||||
|
array = np.asarray(pixel_array)
|
||||||
|
if array.ndim == 2:
|
||||||
|
return array[np.newaxis, :, :]
|
||||||
|
if array.ndim == 3:
|
||||||
|
return array
|
||||||
|
if array.ndim == 4:
|
||||||
|
if array.shape[-1] == 1:
|
||||||
|
return array[..., 0]
|
||||||
|
return np.max(array, axis=-1)
|
||||||
|
raise RuntimeError("Segmentation Mask 像素维度不受支持。")
|
||||||
|
|
||||||
|
|
||||||
|
def resize_label_frame(frame, rows, cols):
|
||||||
|
frame = np.asarray(frame)
|
||||||
|
if frame.shape == (rows, cols):
|
||||||
|
return frame
|
||||||
|
image = Image.fromarray(frame.astype(np.uint16))
|
||||||
|
image = image.resize((cols, rows), Image.Resampling.NEAREST)
|
||||||
|
return np.asarray(image)
|
||||||
|
|
||||||
|
|
||||||
|
def load_segmentation_mask(item_id, segmentation_id):
|
||||||
|
item = find_library_item(item_id)
|
||||||
|
if not item:
|
||||||
|
raise RuntimeError("影像库中没有找到该数据。")
|
||||||
|
segmentation_path = segmentation_file_for_id(item, segmentation_id)
|
||||||
|
if not segmentation_path:
|
||||||
|
raise RuntimeError("没有找到已上传的 DICOM Segmentation Mask。")
|
||||||
|
|
||||||
|
signature = segmentation_signature(segmentation_path)
|
||||||
|
cache_key = f"{item_id}:{safe_filename(segmentation_id)}"
|
||||||
|
with SEGMENTATION_CACHE_LOCK:
|
||||||
|
cached = SEGMENTATION_CACHE.get(cache_key)
|
||||||
|
if cached and cached["signature"] == signature:
|
||||||
|
cached["last_access"] = time.time()
|
||||||
|
return cached["data"]
|
||||||
|
|
||||||
|
dicom_files = sorted_dicom_files(item["dicomPath"])
|
||||||
|
if not dicom_files:
|
||||||
|
raise RuntimeError("该影像数据没有可配准的 CT DICOM。")
|
||||||
|
first_ct = pydicom.dcmread(str(dicom_files[0]), stop_before_pixels=True, force=True)
|
||||||
|
ct_rows = int(getattr(first_ct, "Rows", 0) or 0)
|
||||||
|
ct_cols = int(getattr(first_ct, "Columns", 0) or 0)
|
||||||
|
if ct_rows <= 0 or ct_cols <= 0:
|
||||||
|
raise RuntimeError("CT DICOM 缺少 Rows/Columns 信息,无法配准 Segmentation Mask。")
|
||||||
|
|
||||||
|
ds = pydicom.dcmread(str(segmentation_path), force=True)
|
||||||
|
frames = normalize_segmentation_frames(ds.pixel_array)
|
||||||
|
geometry = dicom_geometry(item["dicomPath"])
|
||||||
|
label_volume = np.zeros((len(dicom_files), ct_rows, ct_cols), dtype=np.uint16)
|
||||||
|
labels = segment_labels_from_dataset(ds)
|
||||||
|
|
||||||
|
for frame_index, frame in enumerate(frames):
|
||||||
|
frame = resize_label_frame(frame, ct_rows, ct_cols)
|
||||||
|
active = frame > 0
|
||||||
|
if not np.any(active):
|
||||||
|
continue
|
||||||
|
|
||||||
|
position = frame_position_patient(ds, frame_index)
|
||||||
|
if geometry and position is not None:
|
||||||
|
voxel = (position - geometry["origin"]) @ geometry["inverse"].T
|
||||||
|
slice_index = int(round(float(voxel[0])))
|
||||||
|
elif len(frames) == len(dicom_files):
|
||||||
|
slice_index = frame_index
|
||||||
|
else:
|
||||||
|
slice_index = min(len(dicom_files) - 1, frame_index)
|
||||||
|
slice_index = max(0, min(len(dicom_files) - 1, slice_index))
|
||||||
|
|
||||||
|
if int(frame.max()) > 1 and not labels:
|
||||||
|
label_volume[slice_index][active] = np.maximum(label_volume[slice_index][active], frame[active].astype(np.uint16))
|
||||||
|
else:
|
||||||
|
label = max(1, frame_segment_number(ds, frame_index))
|
||||||
|
label_volume[slice_index][active] = label
|
||||||
|
labels.setdefault(label, f"Segment {label}")
|
||||||
|
|
||||||
|
unique_labels = sorted(int(value) for value in np.unique(label_volume) if int(value) > 0)
|
||||||
|
if not unique_labels:
|
||||||
|
raise RuntimeError("DICOM Segmentation Mask 中没有可渲染的分割像素。")
|
||||||
|
if not labels:
|
||||||
|
labels = {value: f"Label {value}" for value in unique_labels}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"itemId": item_id,
|
||||||
|
"segId": safe_filename(segmentation_id),
|
||||||
|
"path": segmentation_path,
|
||||||
|
"name": segmentation_path.name.split("_", 1)[1] if "_" in segmentation_path.name else segmentation_path.name,
|
||||||
|
"volume": label_volume,
|
||||||
|
"frameCount": int(frames.shape[0]),
|
||||||
|
"segmentCount": len(unique_labels),
|
||||||
|
"labels": [
|
||||||
|
{"value": int(value), "label": labels.get(int(value), f"Segment {int(value)}")}
|
||||||
|
for value in unique_labels
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
with SEGMENTATION_CACHE_LOCK:
|
||||||
|
SEGMENTATION_CACHE[cache_key] = {
|
||||||
|
"signature": signature,
|
||||||
|
"data": data,
|
||||||
|
"last_access": time.time(),
|
||||||
|
}
|
||||||
|
while len(SEGMENTATION_CACHE) > SEGMENTATION_CACHE_LIMIT:
|
||||||
|
oldest_key = min(
|
||||||
|
SEGMENTATION_CACHE,
|
||||||
|
key=lambda key: SEGMENTATION_CACHE[key].get("last_access", 0),
|
||||||
|
)
|
||||||
|
if oldest_key == cache_key:
|
||||||
|
break
|
||||||
|
SEGMENTATION_CACHE.pop(oldest_key, None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_segmentation(segmentation):
|
||||||
|
return {
|
||||||
|
"segId": segmentation["segId"],
|
||||||
|
"name": segmentation["name"],
|
||||||
|
"frameCount": segmentation["frameCount"],
|
||||||
|
"segmentCount": segmentation["segmentCount"],
|
||||||
|
"labels": segmentation["labels"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_segmentations(item_id):
|
||||||
|
item = find_library_item(item_id)
|
||||||
|
if not item:
|
||||||
|
raise RuntimeError("影像库中没有找到该数据。")
|
||||||
|
root = segmentation_root_for_item(item)
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
segmentations = []
|
||||||
|
for path in sorted(list(root.glob("*.dcm")) + list(root.glob("*.dicom"))):
|
||||||
|
meta = read_json_file(segmentation_meta_path(path), None)
|
||||||
|
if meta:
|
||||||
|
segmentations.append(meta)
|
||||||
|
continue
|
||||||
|
seg_id = path.name.split("_", 1)[0]
|
||||||
|
try:
|
||||||
|
segmentations.append(serialize_segmentation(load_segmentation_mask(item_id, seg_id)))
|
||||||
|
except Exception:
|
||||||
|
segmentations.append({
|
||||||
|
"segId": seg_id,
|
||||||
|
"name": path.name.split("_", 1)[1] if "_" in path.name else path.name,
|
||||||
|
"frameCount": 0,
|
||||||
|
"segmentCount": 0,
|
||||||
|
"labels": [],
|
||||||
|
})
|
||||||
|
return segmentations
|
||||||
|
|
||||||
|
|
||||||
|
def save_uploaded_segmentation(headers, body):
|
||||||
|
if not body:
|
||||||
|
raise RuntimeError("上传的 Segmentation Mask 文件为空。")
|
||||||
|
item_id = safe_filename(unquote(headers.get("x-library-id", "")))
|
||||||
|
item = find_library_item(item_id)
|
||||||
|
if not item:
|
||||||
|
raise RuntimeError("影像库中没有找到要绑定的 DICOM 数据。")
|
||||||
|
|
||||||
|
source_name = safe_segmentation_filename(unquote(headers.get("x-file-name", "segmentation.dcm")))
|
||||||
|
seg_id = uuid.uuid4().hex[:12]
|
||||||
|
root = segmentation_root_for_item(item)
|
||||||
|
safe_mkdir(root)
|
||||||
|
segmentation_path = root / f"{seg_id}_{source_name}"
|
||||||
|
segmentation_path.write_bytes(body)
|
||||||
|
try:
|
||||||
|
segmentation = load_segmentation_mask(item_id, seg_id)
|
||||||
|
meta = serialize_segmentation(segmentation)
|
||||||
|
write_json_file(segmentation_meta_path(segmentation_path), meta)
|
||||||
|
return meta
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
segmentation_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def dicom_geometry(dicom_dir):
|
def dicom_geometry(dicom_dir):
|
||||||
dicom_files = sorted_dicom_files(dicom_dir)
|
dicom_files = sorted_dicom_files(dicom_dir)
|
||||||
if not dicom_files:
|
if not dicom_files:
|
||||||
@@ -512,6 +772,64 @@ def render_mask_only_preview(mask, size):
|
|||||||
return preview_rgba.convert("RGB")
|
return preview_rgba.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def render_segmentation_label_preview(label_slice):
|
||||||
|
labels = np.asarray(label_slice, dtype=np.uint16)
|
||||||
|
rgb = np.zeros((labels.shape[0], labels.shape[1], 3), dtype=np.uint8)
|
||||||
|
rgb[:, :] = np.asarray((8, 13, 28), dtype=np.uint8)
|
||||||
|
for value in sorted(int(item) for item in np.unique(labels) if int(item) > 0):
|
||||||
|
color = SEGMENTATION_COLORS[(value - 1) % len(SEGMENTATION_COLORS)]
|
||||||
|
rgb[labels == value] = color
|
||||||
|
return Image.fromarray(rgb, mode="RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_reformat_index(raw_index, count):
|
||||||
|
if str(raw_index) == "middle":
|
||||||
|
return count // 2
|
||||||
|
try:
|
||||||
|
return int(raw_index)
|
||||||
|
except Exception:
|
||||||
|
return count // 2
|
||||||
|
|
||||||
|
|
||||||
|
def make_segmentation_reformat_preview(item_id, segmentation_id, plane, index):
|
||||||
|
segmentation = load_segmentation_mask(item_id, segmentation_id)
|
||||||
|
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
|
||||||
|
volume = segmentation["volume"]
|
||||||
|
if plane == "coronal":
|
||||||
|
count = volume.shape[1]
|
||||||
|
index = normalize_reformat_index(index, count)
|
||||||
|
index = max(0, min(index, count - 1))
|
||||||
|
label_slice = volume[:, index, :]
|
||||||
|
else:
|
||||||
|
count = volume.shape[2]
|
||||||
|
index = normalize_reformat_index(index, count)
|
||||||
|
index = max(0, min(index, count - 1))
|
||||||
|
label_slice = volume[:, :, index]
|
||||||
|
|
||||||
|
mask_pixels = int(np.count_nonzero(label_slice))
|
||||||
|
cache_dir = PREVIEW_CACHE_DIR / item_id / "segmentation"
|
||||||
|
safe_mkdir(cache_dir)
|
||||||
|
preview_path = cache_dir / f"{plane}_{index:04d}_seg_{safe_filename(segmentation_id)}.png"
|
||||||
|
if not preview_path.exists():
|
||||||
|
preview = render_segmentation_label_preview(label_slice)
|
||||||
|
preview = fit_image(preview, 960, 720)
|
||||||
|
preview.save(preview_path, format="PNG")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
|
||||||
|
"index": index,
|
||||||
|
"count": count,
|
||||||
|
"plane": plane,
|
||||||
|
"window": "segmentation",
|
||||||
|
"windowLabel": "Segmentation Mask",
|
||||||
|
"patientId": segmentation["itemId"],
|
||||||
|
"segId": segmentation["segId"],
|
||||||
|
"maskPixels": mask_pixels,
|
||||||
|
"segmentCount": segmentation["segmentCount"],
|
||||||
|
"labels": segmentation["labels"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def make_library_reformat_preview(item_id, plane, index, window, model_id="", mask_only=False):
|
def make_library_reformat_preview(item_id, plane, index, window, model_id="", mask_only=False):
|
||||||
item = find_library_item(item_id)
|
item = find_library_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -521,14 +839,6 @@ def make_library_reformat_preview(item_id, plane, index, window, model_id="", ma
|
|||||||
window = window if window in VIEWER_WINDOWS else "default"
|
window = window if window in VIEWER_WINDOWS else "default"
|
||||||
volume = load_cached_dicom_volume(item["dicomPath"])
|
volume = load_cached_dicom_volume(item["dicomPath"])
|
||||||
|
|
||||||
def normalize_reformat_index(raw_index, count):
|
|
||||||
if str(raw_index) == "middle":
|
|
||||||
return count // 2
|
|
||||||
try:
|
|
||||||
return int(raw_index)
|
|
||||||
except Exception:
|
|
||||||
return count // 2
|
|
||||||
|
|
||||||
mask = None
|
mask = None
|
||||||
mask_pixels = 0
|
mask_pixels = 0
|
||||||
if plane == "coronal":
|
if plane == "coronal":
|
||||||
@@ -1256,6 +1566,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id, mask_only))
|
self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id, mask_only))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/segmentation/list":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
item_id = params.get("id", [""])[0]
|
||||||
|
self.send_json({"items": list_segmentations(item_id)})
|
||||||
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/segmentation/preview":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
item_id = params.get("id", [""])[0]
|
||||||
|
segmentation_id = params.get("segId", [""])[0]
|
||||||
|
plane = params.get("plane", ["coronal"])[0]
|
||||||
|
index = params.get("index", ["0"])[0]
|
||||||
|
self.send_json(make_segmentation_reformat_preview(item_id, segmentation_id, plane, index))
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/library/info":
|
if parsed.path == "/api/library/info":
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
item_id = params.get("id", [""])[0]
|
item_id = params.get("id", [""])[0]
|
||||||
@@ -1328,6 +1653,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(save_uploaded_stl(self.headers, body), status=201)
|
self.send_json(save_uploaded_stl(self.headers, body), status=201)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/segmentation/upload":
|
||||||
|
body = self.read_bytes()
|
||||||
|
self.send_json(save_uploaded_segmentation(self.headers, body), status=201)
|
||||||
|
return
|
||||||
|
|
||||||
body = self.read_json()
|
body = self.read_json()
|
||||||
if parsed.path == "/api/demo/reset":
|
if parsed.path == "/api/demo/reset":
|
||||||
self.send_json(reset_demo_environment())
|
self.send_json(reset_demo_environment())
|
||||||
@@ -1513,7 +1843,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def send_cors_headers(self):
|
def send_cors_headers(self):
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, x-file-name, x-library-id")
|
||||||
|
|
||||||
def send_json(self, payload, status=200):
|
def send_json(self, payload, status=200):
|
||||||
data = json.dumps(payload, ensure_ascii=False, default=json_default).encode("utf-8")
|
data = json.dumps(payload, ensure_ascii=False, default=json_default).encode("utf-8")
|
||||||
|
|||||||
58
工程分析/实现方案-2026-05-08-03-57-51.md
Normal file
58
工程分析/实现方案-2026-05-08-03-57-51.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 实现方案 - 2026-05-08-03-57-51
|
||||||
|
|
||||||
|
## 方案路径
|
||||||
|
|
||||||
|
将右侧“Mask 展示”从 STL/3D 几何结果展示改为真实 DICOM Segmentation Mask 的二维实心切片展示。模型切分操作只决定需要展示的切分位置和上下端点;实际像素内容必须来自与当前 DICOM 数据配准的语义分割 mask 体数据。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- `web_backend.py`
|
||||||
|
- 新增 segmentation mask 数据发现、读取、缓存和切片渲染逻辑。
|
||||||
|
- 新增或扩展 mask 预览接口,返回切分上侧/下侧两张二维实心 mask 图片。
|
||||||
|
- 保留 STL 切分用于确定范围或几何参考,但不再用 STL Cap 作为最终 mask 图像数据源。
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- 调整模型切分触发后的“Mask 展示”请求和渲染逻辑。
|
||||||
|
- 右侧面板固定展示两张二维实心 Segmentation Mask 图片。
|
||||||
|
- 对无 segmentation 数据、无交集、加载失败分别给出状态。
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
- 完成后追加本轮关键问题、原因、解决方案和后续避免方式。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 数据源梳理:
|
||||||
|
- 检查 `web_library/` 和现有接口中是否已有 DICOM SEG、RTSTRUCT、NIfTI、NRRD、PNG mask 序列或其他语义分割文件。
|
||||||
|
- 当前未发现现成 segmentation 数据源,因此新增“上传/关联 Segmentation Mask”的后端入口和前端入口,避免继续从 STL 伪造 mask。
|
||||||
|
2. 后端 mask 体数据读取:
|
||||||
|
- 优先支持 DICOM SEG / DICOM label-map 文件。
|
||||||
|
- 将 mask 读取为与 CT 体数据对齐的三维 label map。
|
||||||
|
- 使用缓存避免每次切分重复读取整套 mask。
|
||||||
|
3. 后端二维实心截面渲染:
|
||||||
|
- 根据当前模型切分范围计算上侧/下侧两张目标切片。
|
||||||
|
- 从 Segmentation Mask label map 中取对应切片。
|
||||||
|
- 生成二维实心 mask-only PNG,而不是 CT 叠加图、STL Cap 或点云投影。
|
||||||
|
- 多标签情况下保留标签差异,可按固定颜色表渲染。
|
||||||
|
4. 前端“Mask 展示”调整:
|
||||||
|
- 点击“模型切分”后,右侧面板请求新的 segmentation mask 双图接口。
|
||||||
|
- 面板标题保持“Mask 展示”,内容为两张二维图片:上侧切面、下侧切面。
|
||||||
|
- 移除或隐藏任何 3D 外壳/点云/半透明模型展示路径。
|
||||||
|
5. 错误与降级处理:
|
||||||
|
- 若没有绑定 segmentation mask 数据,提示需要上传或关联 DICOM Segmentation Mask。
|
||||||
|
- 若某一切面 mask 为空,显示“该切面无分割区域”,但不生成假 mask。
|
||||||
|
6. 验证通过后更新经验记录、提交 Gitea,并重新部署。
|
||||||
|
|
||||||
|
## 回滚思路
|
||||||
|
|
||||||
|
若新接口或前端展示出现问题,可回滚 `web_backend.py` 中新增 segmentation mask 读取/渲染逻辑和 `WebSite/src/App.tsx` 中“Mask 展示”的请求渲染改动,恢复当前 STL mask-only 双图逻辑。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 不将 STL Cap、封闭面或几何填充作为最终 mask 数据源。
|
||||||
|
- 对 segmentation 数据缺失做显式错误提示,不用临时绘制图形代替。
|
||||||
|
- DICOM SEG 坐标对齐需严格参考 CT 的方向、间距、原点和切片顺序。
|
||||||
|
- 若缺少测试数据,只做接口和类型验证不足以证明医学对齐正确,必须标注残余风险。
|
||||||
|
|
||||||
|
## 需要用户确认
|
||||||
|
|
||||||
|
- 用户已确认方案,允许新增 Segmentation Mask 上传/关联入口。
|
||||||
|
- “上、下两个视角”本次按当前切分范围的起点帧/终点帧实现。
|
||||||
|
- 如果已有真实 segmentation mask 数据,请提供其所在目录或文件格式说明。
|
||||||
55
工程分析/测试方案-2026-05-08-03-57-51.md
Normal file
55
工程分析/测试方案-2026-05-08-03-57-51.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 测试方案 - 2026-05-08-03-57-51
|
||||||
|
|
||||||
|
## 测试范围
|
||||||
|
|
||||||
|
- Segmentation Mask 数据发现或上传/关联流程。
|
||||||
|
- 后端 mask 读取、缓存、切片提取和 PNG 渲染。
|
||||||
|
- 前端模型切分触发后右侧“Mask 展示”双图渲染。
|
||||||
|
- 无 segmentation 数据、空 mask、加载失败等状态。
|
||||||
|
- 既有 DICOM 阅览、STL 上传和模型切分范围控件不发生回归。
|
||||||
|
|
||||||
|
## 测试命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m py_compile web_backend.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WebSite
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
后端烟测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
from web_backend import list_segmentations
|
||||||
|
print(list_segmentations('demo_ori_head_ct'))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
使用临时 DICOM mask 文件验证上传解析和 segmentation preview 生成,验证后删除临时文件。
|
||||||
|
|
||||||
|
## 手工验证点
|
||||||
|
|
||||||
|
- 打开逆向工作区或 DICOM 阅览,加载 CT 数据。
|
||||||
|
- 上传或关联真实 DICOM Segmentation Mask。
|
||||||
|
- 执行“模型切分”后,右侧“Mask 展示”显示两张二维实心切面图片。
|
||||||
|
- 两张图分别对应切分位置上侧/下侧,或确认后的起点帧/终点帧。
|
||||||
|
- 图片像素来自 segmentation mask;不显示 3D 外壳、点云、空心模型或 STL Cap。
|
||||||
|
- 没有 segmentation 数据时,界面提示需要上传/关联 mask,不生成假图。
|
||||||
|
- 切换显示平面或调整切分范围后,双图刷新到对应切面。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- “Mask 展示”在模型切分后只显示二维实心 Segmentation Mask 双图。
|
||||||
|
- 双图数据源为真实 DICOM 语义分割 mask,而非 STL 几何封顶或点云投影。
|
||||||
|
- 无 mask 数据时不伪造结果。
|
||||||
|
- `python -m py_compile web_backend.py`、`npm run lint`、`npm run build` 均通过。
|
||||||
|
- 项目重新部署后 `http://192.168.3.11:3005` 可访问。
|
||||||
|
|
||||||
|
## 无法测试的风险
|
||||||
|
|
||||||
|
- 若当前环境没有真实 DICOM SEG/语义分割数据,只能验证缺失数据提示和代码结构,无法验证医学空间对齐准确性。
|
||||||
|
- 若 segmentation mask 来源不是标准 DICOM SEG,可能需要针对实际格式补充解析和配准逻辑。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -127,3 +127,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
涉及 mask 或语义分割结果展示时,应区分“CT 背景叠加图”和“mask-only 结果图”。如果用户要求展示模型切面或分割形态,优先提供独立 mask 图片,并在 UI 上明确区分普通阅览与切分结果。
|
涉及 mask 或语义分割结果展示时,应区分“CT 背景叠加图”和“mask-only 结果图”。如果用户要求展示模型切面或分割形态,优先提供独立 mask 图片,并在 UI 上明确区分普通阅览与切分结果。
|
||||||
|
|
||||||
|
## 2026-05-08-03-57-51 接入 DICOM SEG 双切面展示
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户进一步明确右侧“Mask 展示”不能显示切分后的三维模型外壳、点云、空心模型,也不能用 STL 截面封顶 Cap 作为结果;模型切分后应展示上、下两个二维实心切面,并且像素数据必须来自 DICOM 语义分割影像。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
此前实现虽然把右侧改成了双图,但数据仍可来自 STL 与 DICOM 平面求交后的 mask-only 渲染。该结果属于几何投影/轮廓填充,不等价于真实 DICOM Segmentation Mask label map。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
新增与影像库条目绑定的 DICOM SEG 上传、列表和预览接口,后端将 SEG 像素解析成与 CT 体数据对齐的三维 label map,并按当前切分范围上、下端点渲染二维实心 mask 图片。前端新增 DICOM SEG 上传入口,模型切分后的“Mask 展示”改为请求 segmentation 预览接口;没有绑定 SEG 时只提示缺少数据,不再回退到 STL 生成假结果。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
凡是用户明确要求 DICOM 语义分割或 Segmentation Mask 时,不能用 STL、mesh cap、点云投影或图形填充替代。实现前应先确认真实 label map 数据源;若数据源缺失,界面应提示上传或关联,而不是生成看似合理的伪 mask。
|
||||||
|
|||||||
52
工程分析/需求分析-2026-05-08-03-57-51.md
Normal file
52
工程分析/需求分析-2026-05-08-03-57-51.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 需求分析 - 2026-05-08-03-57-51
|
||||||
|
|
||||||
|
## 原始需求
|
||||||
|
|
||||||
|
【需求模块】:逆向工作区 -> 右侧“Mask 展示”面板
|
||||||
|
|
||||||
|
【触发条件】:用户在可视化工具栏点击执行“模型切分”操作后。
|
||||||
|
|
||||||
|
【当前表现】:“Mask 展示”区目前显示的仍是切分后的三维模型外壳/点云,呈半透明或空心状态。
|
||||||
|
|
||||||
|
【期望表现】:“Mask 展示”区需要切换显示为二维的实心截面图像。需要同时展示模型被切开处上、下两个视角的切面。
|
||||||
|
|
||||||
|
【数据源要求】:这两个实心切面不能仅仅是 3D 模型的截面封顶 Cap,而必须直接映射并渲染对应的 DICOM 语义分割影像 Segmentation Mask。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 模型切分执行后,右侧“Mask 展示”不再显示 3D 外壳、半透明点云、空心模型或几何封顶。
|
||||||
|
- “Mask 展示”改为二维图像区域,并同时展示切分位置上侧、下侧两张实心截面图。
|
||||||
|
- 两张截面图的数据源必须来自 DICOM 语义分割影像/Segmentation Mask,而不是由 STL 外壳临时封顶生成。
|
||||||
|
- 截面图需与当前 DICOM 切分范围和显示平面保持一致。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- 前端:
|
||||||
|
- 逆向工作区或 DICOM 阅览弹窗中的“Mask 展示”条件渲染。
|
||||||
|
- 模型切分执行后的右侧双图布局、加载状态和无 mask 状态展示。
|
||||||
|
- 后端:
|
||||||
|
- 当前 `web_backend.py` 已有 STL 上传、STL 与 DICOM 切片平面求交 mask 生成逻辑。
|
||||||
|
- 需要新增或接入真实 DICOM Segmentation Mask 数据读取、缓存和二维切片渲染能力。
|
||||||
|
- 数据:
|
||||||
|
- 需要确认工程中是否已有 DICOM SEG、RTSTRUCT、NIfTI/NRRD mask、PNG mask 序列或其他语义分割数据源。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 必须遵循仓库 `AGENTS.md` 中的项目修改工作流。
|
||||||
|
- 本轮使用统一开始时间戳 `2026-05-08-03-57-51`。
|
||||||
|
- 实现方案和测试方案写完后,必须等待用户二次人工审核确认;未经确认不得修改业务代码。
|
||||||
|
- 不得用 STL Cap、几何封闭面、点云投影或装饰性填充冒充 DICOM Segmentation Mask。
|
||||||
|
- 不得提交 DICOM 原始数据、STL 模型、mask 缓存图、构建产物或凭据。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 当前仓库现有 mask 逻辑主要来自 STL 三角面与 DICOM 平面的交线填充,不等同于真实 DICOM Segmentation Mask。
|
||||||
|
- 如果本项目数据目录中没有现成 segmentation mask 数据源,则仅靠 STL 无法满足“直接映射 DICOM 语义分割影像”的要求,需要先补充数据上传/关联入口。
|
||||||
|
- DICOM SEG、RTSTRUCT、NIfTI、NRRD、PNG mask 序列的数据坐标系和 CT 坐标系可能不同,必须处理 spacing、origin、orientation、slice order 和标签值映射。
|
||||||
|
- “上、下两个视角”需要在实现中落到明确的切片端点:通常对应当前切分范围的起点帧和终点帧,或当前切割平面的上下两侧相邻 mask 切片。
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
- 当前项目是否已有真实 DICOM Segmentation Mask 文件或目录。如果没有,应由本次新增上传/关联入口,还是由用户先提供数据路径。
|
||||||
|
- “上、下两个视角”是指切分范围的起点帧/终点帧,还是同一切割平面上下两侧相邻切片。
|
||||||
|
- 需要展示的 mask 标签是否只有一个目标结构,还是多标签语义分割并按标签分别着色。
|
||||||
Reference in New Issue
Block a user