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>
|
||||
|
||||
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"
|
||||
STL_MODEL_CACHE = {}
|
||||
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 = {
|
||||
"default": {"label": "默认", "low": -500, "high": 1200},
|
||||
"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"
|
||||
|
||||
|
||||
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):
|
||||
username = str(username or "").strip()
|
||||
return username or "anonymous"
|
||||
@@ -343,6 +365,244 @@ def load_stl_model(model_id):
|
||||
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):
|
||||
dicom_files = sorted_dicom_files(dicom_dir)
|
||||
if not dicom_files:
|
||||
@@ -512,6 +772,64 @@ def render_mask_only_preview(mask, size):
|
||||
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):
|
||||
item = find_library_item(item_id)
|
||||
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"
|
||||
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_pixels = 0
|
||||
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))
|
||||
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":
|
||||
params = parse_qs(parsed.query)
|
||||
item_id = params.get("id", [""])[0]
|
||||
@@ -1328,6 +1653,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(save_uploaded_stl(self.headers, body), status=201)
|
||||
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()
|
||||
if parsed.path == "/api/demo/reset":
|
||||
self.send_json(reset_demo_environment())
|
||||
@@ -1513,7 +1843,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def send_cors_headers(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
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):
|
||||
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. 后续如何避免问题
|
||||
|
||||
涉及 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