From fe4b90abcdb1d9660c7795126e51762255592dd0 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 8 May 2026 04:19:24 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-08-03-57-51=20=E6=8E=A5=E5=85=A5DICOM?= =?UTF-8?q?=20SEG=E5=8F=8C=E5=88=87=E9=9D=A2=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/App.tsx | 120 ++++++-- web_backend.py | 348 ++++++++++++++++++++++- 工程分析/实现方案-2026-05-08-03-57-51.md | 58 ++++ 工程分析/测试方案-2026-05-08-03-57-51.md | 55 ++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-08-03-57-51.md | 52 ++++ 6 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 工程分析/实现方案-2026-05-08-03-57-51.md create mode 100644 工程分析/测试方案-2026-05-08-03-57-51.md create mode 100644 工程分析/需求分析-2026-05-08-03-57-51.md diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 9c17cae..a5b6d44 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -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(null); const [isUploadingStl, setIsUploadingStl] = useState(false); + const [segmentationMask, setSegmentationMask] = useState(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(null); const zipUploadInputRef = useRef(null); const stlUploadInputRef = useRef(null); + const segmentationUploadInputRef = useRef(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) => { const file = event.target.files?.[0]; event.target.value = ''; @@ -1076,6 +1115,33 @@ export default function App() { } }; + const handleSegmentationSelected = async (event: React.ChangeEvent) => { + 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} /> +
@@ -2148,7 +2221,7 @@ export default function App() {

模型切分

- {stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 后启用真实 mask'} + {stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 设定切分范围'}

- +
+ + +
+
+

Segmentation Mask

+

+ {segmentationMask ? `${segmentationMask.name} · ${segmentationMask.segmentCount} 标签` : '未绑定 DICOM SEG'} +

+
{isModelSlicingEnabled && (
@@ -2206,7 +2294,7 @@ export default function App() { />

- 两个端点可交叉;显示时分别按起点帧和终点帧切 STL。 + 两个端点可交叉;右侧按 DICOM SEG 渲染上下切面。

)} @@ -2235,7 +2323,7 @@ export default function App() {

Mask 展示

- {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'}

@@ -2244,8 +2332,8 @@ export default function App() {
{[ - { 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 => (
{item.preview?.imageUrl && !modelMaskError ? ( @@ -2253,7 +2341,7 @@ export default function App() { ) : (
-

{modelMaskError || '等待 STL mask'}

+

{modelMaskError || '等待 Segmentation Mask'}

)}
@@ -2264,7 +2352,7 @@ export default function App() {

- MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'} + MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无分割区域'}

diff --git a/web_backend.py b/web_backend.py index 090570c..f9fdb97 100644 --- a/web_backend.py +++ b/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") diff --git a/工程分析/实现方案-2026-05-08-03-57-51.md b/工程分析/实现方案-2026-05-08-03-57-51.md new file mode 100644 index 0000000..e4fc99f --- /dev/null +++ b/工程分析/实现方案-2026-05-08-03-57-51.md @@ -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 数据,请提供其所在目录或文件格式说明。 diff --git a/工程分析/测试方案-2026-05-08-03-57-51.md b/工程分析/测试方案-2026-05-08-03-57-51.md new file mode 100644 index 0000000..9ba97d2 --- /dev/null +++ b/工程分析/测试方案-2026-05-08-03-57-51.md @@ -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,可能需要针对实际格式补充解析和配准逻辑。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index ffa20d6..8696438 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.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。 diff --git a/工程分析/需求分析-2026-05-08-03-57-51.md b/工程分析/需求分析-2026-05-08-03-57-51.md new file mode 100644 index 0000000..2ed1962 --- /dev/null +++ b/工程分析/需求分析-2026-05-08-03-57-51.md @@ -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 标签是否只有一个目标结构,还是多标签语义分割并按标签分别着色。