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; 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>
<div className="grid grid-cols-2 gap-2">
<button <button
onClick={uploadStlModel} onClick={uploadStlModel}
disabled={isUploadingStl} 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" 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 模型'} {isUploadingStl ? '解析中...' : '上传 STL'}
</button> </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>

View File

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

View 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 数据,请提供其所在目录或文件格式说明。

View 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可能需要针对实际格式补充解析和配准逻辑。

View File

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

View 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 标签是否只有一个目标结构,还是多标签语义分割并按标签分别着色。