From 8e0e54fc3c507a6dab3d35095dacdd04da01202f Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 8 May 2026 02:45:12 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-08-02-36-12=20=E5=AE=9E=E7=8E=B0STL?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=87=E5=88=86mask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/App.tsx | 215 ++++++++++++++++++- web_backend.py | 251 ++++++++++++++++++++++- 工程分析/实现方案-2026-05-08-02-36-12.md | 65 ++++++ 工程分析/测试方案-2026-05-08-02-36-12.md | 94 +++++++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-08-02-36-12.md | 53 +++++ 6 files changed, 691 insertions(+), 5 deletions(-) create mode 100644 工程分析/实现方案-2026-05-08-02-36-12.md create mode 100644 工程分析/测试方案-2026-05-08-02-36-12.md create mode 100644 工程分析/需求分析-2026-05-08-02-36-12.md diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index b5edeff..07db56a 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -137,6 +137,14 @@ type LibraryViewerPreview = { window: string; windowLabel: string; patientId: string; + modelId?: string; + maskPixels?: number; +}; + +type StlModel = { + modelId: string; + name: string; + triangleCount: number; }; type StoredDeformationJob = { @@ -386,8 +394,18 @@ export default function App() { const [viewerPreview, setViewerPreview] = useState(null); const [isViewerLoading, setIsViewerLoading] = useState(false); const [viewerError, setViewerError] = useState(''); + const [stlModel, setStlModel] = useState(null); + const [isUploadingStl, setIsUploadingStl] = useState(false); + const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false); + const [modelClipStart, setModelClipStart] = useState(0); + const [modelClipEnd, setModelClipEnd] = useState(0); + const [modelStartPreview, setModelStartPreview] = useState(null); + const [modelEndPreview, setModelEndPreview] = useState(null); + const [isModelMaskLoading, setIsModelMaskLoading] = useState(false); + const [modelMaskError, setModelMaskError] = useState(''); const folderUploadInputRef = useRef(null); const zipUploadInputRef = useRef(null); + const stlUploadInputRef = useRef(null); // --- Simulation State (Workspace) --- const [cervicalRotation, setCervicalRotation] = useState(14.5); @@ -427,6 +445,9 @@ export default function App() { const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0]; const videoSourceInputDir = selectedInputDir; const isVideoSourceReady = Boolean(videoSourceInputDir); + const viewerFrameCount = Math.max(1, viewerPreview?.count || modelStartPreview?.count || modelEndPreview?.count || libraryViewerItem?.fileCount || 1); + const clampedModelStart = Math.max(0, Math.min(viewerFrameCount - 1, modelClipStart)); + const clampedModelEnd = Math.max(0, Math.min(viewerFrameCount - 1, modelClipEnd)); useEffect(() => { if (!activeUserMenu) return; @@ -929,6 +950,12 @@ export default function App() { setDebouncedViewerSliceIndex('middle'); setViewerPreview(null); setViewerError(''); + setIsModelSlicingEnabled(false); + setModelClipStart(0); + setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1)); + setModelStartPreview(null); + setModelEndPreview(null); + setModelMaskError(''); }; const closeLibraryViewer = () => { @@ -936,6 +963,10 @@ export default function App() { setViewerPreview(null); setViewerError(''); setIsViewerLoading(false); + setIsModelSlicingEnabled(false); + setModelStartPreview(null); + setModelEndPreview(null); + setModelMaskError(''); }; useEffect(() => { @@ -970,6 +1001,81 @@ export default function App() { return () => controller.abort(); }, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]); + useEffect(() => { + if (!libraryViewerItem || !viewerPreview?.count) return; + setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current))); + setModelClipEnd(current => { + if (current <= 0) return viewerPreview.count - 1; + return Math.max(0, Math.min(viewerPreview.count - 1, current)); + }); + }, [libraryViewerItem?.id, viewerPreview?.count]); + + useEffect(() => { + if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) 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)}` + ); + + setIsModelMaskLoading(true); + setModelMaskError(''); + Promise.all([ + fetch(makeUrl(clampedModelStart), { signal: controller.signal }).then(async response => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error || '起点帧 mask 生成失败'); + return data as LibraryViewerPreview; + }), + fetch(makeUrl(clampedModelEnd), { signal: controller.signal }).then(async response => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error || '终点帧 mask 生成失败'); + return data as LibraryViewerPreview; + }), + ]) + .then(([startPreview, endPreview]) => { + setModelStartPreview(startPreview); + setModelEndPreview(endPreview); + }) + .catch(error => { + if ((error as Error).name !== 'AbortError') setModelMaskError((error as Error).message); + }) + .finally(() => { + if (!controller.signal.aborted) setIsModelMaskLoading(false); + }); + + return () => controller.abort(); + }, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, viewerPlane, viewerWindow, clampedModelStart, clampedModelEnd]); + + const uploadStlModel = () => { + stlUploadInputRef.current?.click(); + }; + + const handleStlSelected = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + setIsUploadingStl(true); + try { + const response = await fetch(`${API_BASE}/api/model/upload`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sla', + 'x-file-name': encodeURIComponent(file.name), + }, + body: await file.arrayBuffer(), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'STL 上传失败'); + setStlModel(data); + setIsModelSlicingEnabled(true); + showToast(`已载入 STL:${data.triangleCount || 0} 个三角面`); + } catch (error) { + showToast((error as Error).message); + } finally { + setIsUploadingStl(false); + } + }; + const changePassword = (userId: string, newPass: string) => { setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u)); setPwChangeInput(''); @@ -1972,6 +2078,13 @@ export default function App() { +
@@ -2011,6 +2124,7 @@ export default function App() {
+ {!isModelSlicingEnabled && (

切片

@@ -2027,6 +2141,74 @@ export default function App() { className="w-full h-1.5 accent-blue-600 cursor-pointer" />
+ )} + +
+
+
+

模型切分

+

+ {stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 后启用真实 mask'} +

+
+ +
+ + + {isModelSlicingEnabled && ( +
+
+ 起点 {clampedModelStart + 1} + 终点 {clampedModelEnd + 1} +
+
+
+
+ setModelClipStart(parseInt(event.target.value, 10))} + className="absolute inset-x-0 top-0 w-full h-8 appearance-none bg-transparent accent-blue-600 cursor-pointer" + /> + setModelClipEnd(parseInt(event.target.value, 10))} + className="absolute inset-x-0 top-0 w-full h-8 appearance-none bg-transparent accent-orange-500 cursor-pointer" + /> +
+

+ 两个端点可交叉;显示时分别按起点帧和终点帧切 STL。 +

+
+ )} +
@@ -2045,7 +2227,36 @@ export default function App() {
- {viewerPreview?.imageUrl && !viewerError ? ( + {isModelSlicingEnabled && stlModel ? ( +
+ {[ + { label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' }, + { label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' }, + ].map(item => ( +
+ {item.preview?.imageUrl && !modelMaskError ? ( + + ) : ( +
+ +

{modelMaskError || '等待 STL mask'}

+
+ )} +
+

{item.label}

+

+ {item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'} +

+
+
+

+ MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'} +

+
+
+ ))} +
+ ) : viewerPreview?.imageUrl && !viewerError ? ( ) : (
@@ -2053,7 +2264,7 @@ export default function App() {

{viewerError || '等待影像载入'}

)} - {isViewerLoading && ( + {(isViewerLoading || isModelMaskLoading) && (
载入中...
diff --git a/web_backend.py b/web_backend.py index 8f9032a..b6d7c67 100644 --- a/web_backend.py +++ b/web_backend.py @@ -2,6 +2,7 @@ import base64 import json import os import shutil +import struct import threading import time import traceback @@ -17,6 +18,7 @@ from urllib.parse import parse_qs, quote, unquote, urlparse os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib") import pydicom +import numpy as np from pydicom.multival import MultiValue from PIL import Image, ImageDraw @@ -50,6 +52,9 @@ RESULT_DIR = APP_DIR / "web_results" JOBS_META = RESULT_DIR / "jobs.json" USER_TASKS_META = RESULT_DIR / "user_tasks.json" PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache" +MODEL_DIR = LIBRARY_DIR / "_stl_models" +STL_MODEL_CACHE = {} +STL_MODEL_CACHE_LOCK = threading.Lock() VIEWER_WINDOWS = { "default": {"label": "默认", "low": -500, "high": 1200}, "bone": {"label": "骨窗", "low": -500, "high": 1800}, @@ -73,6 +78,11 @@ def safe_filename(name): return "".join(char if char.isalnum() or char in "._-" else "_" for char in Path(name).name) +def safe_model_filename(name): + name = safe_filename(name or "model.stl") + return name if name.lower().endswith(".stl") else f"{name}.stl" + + def normalized_username(username): username = str(username or "").strip() return username or "anonymous" @@ -231,6 +241,222 @@ def clear_dicom_caches(dicom_dir=None): DICOM_VOLUME_CACHE.pop(cache_key, None) +def parse_ascii_stl(text): + vertices = [] + triangles = [] + for line in text.splitlines(): + parts = line.strip().split() + if len(parts) == 4 and parts[0].lower() == "vertex": + try: + vertices.append([float(parts[1]), float(parts[2]), float(parts[3])]) + except ValueError: + vertices = [] + break + if len(vertices) == 3: + triangles.append(vertices) + vertices = [] + if not triangles: + raise RuntimeError("STL 文件中没有可解析的三角面。") + return np.asarray(triangles, dtype=np.float32) + + +def parse_binary_stl(data): + if len(data) < 84: + raise RuntimeError("STL 文件过小,无法解析。") + triangle_count = struct.unpack_from(" len(data): + raise RuntimeError("Binary STL 三角面数量异常。") + + triangles = np.zeros((triangle_count, 3, 3), dtype=np.float32) + offset = 84 + for index in range(triangle_count): + values = struct.unpack_from("<12fH", data, offset) + triangles[index] = np.asarray(values[3:12], dtype=np.float32).reshape(3, 3) + offset += 50 + return triangles + + +def parse_stl_bytes(data): + try: + text = data.decode("utf-8", errors="ignore") + except Exception: + text = "" + if text.lstrip().lower().startswith("solid"): + try: + return parse_ascii_stl(text) + except Exception: + pass + return parse_binary_stl(data) + + +def save_uploaded_stl(headers, body): + if not body: + raise RuntimeError("上传的 STL 文件为空。") + safe_mkdir(MODEL_DIR) + source_name = safe_model_filename(unquote(headers.get("x-file-name", "model.stl"))) + model_id = uuid.uuid4().hex[:12] + model_path = MODEL_DIR / f"{model_id}_{source_name}" + model_path.write_bytes(body) + triangles = parse_stl_bytes(body) + bounds_min = triangles.reshape(-1, 3).min(axis=0).tolist() + bounds_max = triangles.reshape(-1, 3).max(axis=0).tolist() + with STL_MODEL_CACHE_LOCK: + STL_MODEL_CACHE[model_id] = { + "path": model_path, + "triangles": triangles, + "name": source_name, + "bounds": [bounds_min, bounds_max], + } + return { + "modelId": model_id, + "name": source_name, + "triangleCount": int(triangles.shape[0]), + "bounds": [bounds_min, bounds_max], + } + + +def load_stl_model(model_id): + model_id = safe_filename(model_id) + if not model_id: + raise RuntimeError("模型 ID 为空。") + with STL_MODEL_CACHE_LOCK: + cached = STL_MODEL_CACHE.get(model_id) + if cached: + return cached + + matches = list(MODEL_DIR.glob(f"{model_id}_*.stl")) + if not matches: + raise RuntimeError("没有找到已上传的 STL 模型。") + model_path = matches[0] + triangles = parse_stl_bytes(model_path.read_bytes()) + bounds_min = triangles.reshape(-1, 3).min(axis=0).tolist() + bounds_max = triangles.reshape(-1, 3).max(axis=0).tolist() + model = { + "path": model_path, + "triangles": triangles, + "name": model_path.name.split("_", 1)[1] if "_" in model_path.name else model_path.name, + "bounds": [bounds_min, bounds_max], + } + with STL_MODEL_CACHE_LOCK: + STL_MODEL_CACHE[model_id] = model + return model + + +def dicom_geometry(dicom_dir): + dicom_files = sorted_dicom_files(dicom_dir) + if not dicom_files: + return None + try: + first = pydicom.dcmread(str(dicom_files[0]), stop_before_pixels=True, force=True) + last = pydicom.dcmread(str(dicom_files[-1]), stop_before_pixels=True, force=True) + orientation = np.asarray(first.ImageOrientationPatient, dtype=np.float64) + col_dir = orientation[:3] + row_dir = orientation[3:] + slice_dir = np.cross(col_dir, row_dir) + pixel_spacing = np.asarray(first.PixelSpacing, dtype=np.float64) + row_spacing = float(pixel_spacing[0]) + col_spacing = float(pixel_spacing[1]) + first_pos = np.asarray(first.ImagePositionPatient, dtype=np.float64) + last_pos = np.asarray(last.ImagePositionPatient, dtype=np.float64) + slice_spacing = float(np.linalg.norm(last_pos - first_pos) / max(1, len(dicom_files) - 1)) + if slice_spacing <= 0: + slice_spacing = float(getattr(first, "SliceThickness", 1) or 1) + basis = np.column_stack([ + slice_dir * slice_spacing, + row_dir * row_spacing, + col_dir * col_spacing, + ]) + inverse = np.linalg.inv(basis) + return { + "origin": first_pos, + "inverse": inverse, + } + except Exception: + return None + + +def stl_triangles_to_voxels(triangles, dicom_dir): + geometry = dicom_geometry(dicom_dir) + if not geometry: + return triangles.astype(np.float32) + points = triangles.reshape(-1, 3).astype(np.float64) + voxel_points = (points - geometry["origin"]) @ geometry["inverse"].T + return voxel_points.reshape(triangles.shape).astype(np.float32) + + +def triangle_plane_segment(triangle, axis, value): + intersections = [] + for start, end in [(triangle[0], triangle[1]), (triangle[1], triangle[2]), (triangle[2], triangle[0])]: + start_delta = float(start[axis] - value) + end_delta = float(end[axis] - value) + if abs(start_delta) < 1e-4 and abs(end_delta) < 1e-4: + continue + if abs(start_delta) < 1e-4: + intersections.append(start) + if start_delta * end_delta < 0: + t = start_delta / (start_delta - end_delta) + intersections.append(start + t * (end - start)) + elif abs(end_delta) < 1e-4: + intersections.append(end) + + unique = [] + for point in intersections: + if not any(np.linalg.norm(point - existing) < 1e-3 for existing in unique): + unique.append(point) + if len(unique) >= 2: + return unique[0], unique[1] + return None + + +def make_stl_slice_mask(triangles, plane, index, image_shape): + height, width = image_shape + axis = 1 if plane == "coronal" else 2 + mask_image = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask_image) + segment_count = 0 + + for triangle in triangles: + segment = triangle_plane_segment(triangle, axis, index) + if not segment: + continue + points = [] + for point in segment: + if plane == "coronal": + x_value = float(point[2]) + else: + x_value = float(point[1]) + y_value = float(point[0]) + points.append((x_value, y_value)) + draw.line(points, fill=255, width=2) + segment_count += 1 + + if segment_count == 0: + return None, 0 + + mask = np.asarray(mask_image) > 0 + try: + from scipy.ndimage import binary_fill_holes + filled = binary_fill_holes(mask) + if int(filled.sum()) > int(mask.sum()): + mask = filled + except Exception: + pass + mask_pixels = int(mask.sum()) + if mask_pixels == 0: + return None, 0 + return Image.fromarray((mask.astype(np.uint8) * 255), mode="L"), mask_pixels + + +def overlay_mask_on_preview(preview, mask): + overlay = Image.new("RGBA", preview.size, (255, 120, 20, 0)) + alpha = mask.resize(preview.size, Image.Resampling.NEAREST) + overlay.putalpha(alpha.point(lambda value: 118 if value else 0)) + preview_rgba = preview.convert("RGBA") + preview_rgba.alpha_composite(overlay) + return preview_rgba.convert("RGB") + + def find_library_item(item_id): return next((item for item in list_library() if item["id"] == item_id), None) @@ -273,7 +499,7 @@ def make_library_slice_preview(item_id, index): } -def make_library_reformat_preview(item_id, plane, index, window): +def make_library_reformat_preview(item_id, plane, index, window, model_id=""): item = find_library_item(item_id) if not item: raise RuntimeError("影像库中没有找到该数据。") @@ -290,6 +516,8 @@ def make_library_reformat_preview(item_id, plane, index, window): except Exception: return count // 2 + mask = None + mask_pixels = 0 if plane == "coronal": count = volume.shape[1] index = normalize_reformat_index(index, count) @@ -301,12 +529,20 @@ def make_library_reformat_preview(item_id, plane, index, window): index = max(0, min(index, count - 1)) image = volume[:, :, index] + if model_id: + model = load_stl_model(model_id) + triangles = stl_triangles_to_voxels(model["triangles"], item["dicomPath"]) + mask, mask_pixels = make_stl_slice_mask(triangles, plane, index, image.shape) + cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat" safe_mkdir(cache_dir) - preview_path = cache_dir / f"{plane}_{window}_{index:04d}.png" + model_suffix = f"_model_{safe_filename(model_id)}" if model_id else "" + preview_path = cache_dir / f"{plane}_{window}_{index:04d}{model_suffix}.png" if not preview_path.exists(): preset = VIEWER_WINDOWS[window] preview = Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB") + if mask is not None: + preview = overlay_mask_on_preview(preview, mask) preview = fit_image(preview, 960, 720) preview.save(preview_path, format="PNG") @@ -318,6 +554,8 @@ def make_library_reformat_preview(item_id, plane, index, window): "window": window, "windowLabel": VIEWER_WINDOWS[window]["label"], "patientId": item["patientId"], + "modelId": model_id, + "maskPixels": mask_pixels, } @@ -995,7 +1233,8 @@ class Handler(BaseHTTPRequestHandler): plane = params.get("plane", ["coronal"])[0] index = params.get("index", ["0"])[0] window = params.get("window", ["default"])[0] - self.send_json(make_library_reformat_preview(item_id, plane, index, window)) + model_id = params.get("modelId", [""])[0] + self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id)) return if parsed.path == "/api/library/info": @@ -1065,6 +1304,11 @@ class Handler(BaseHTTPRequestHandler): self.send_json(upload_library_item(self.headers, body), status=201) return + if parsed.path == "/api/model/upload": + body = self.read_bytes() + self.send_json(save_uploaded_stl(self.headers, body), status=201) + return + body = self.read_json() if parsed.path == "/api/demo/reset": self.send_json(reset_demo_environment()) @@ -1267,6 +1511,7 @@ def main(): safe_mkdir(APP_DIR / "ppt_video") safe_mkdir(LIBRARY_DIR) safe_mkdir(RESULT_DIR) + safe_mkdir(MODEL_DIR) load_persisted_jobs() server = ThreadingHTTPServer((HOST, PORT), Handler) print(f"Head CT Morph backend running at http://{HOST}:{PORT}") diff --git a/工程分析/实现方案-2026-05-08-02-36-12.md b/工程分析/实现方案-2026-05-08-02-36-12.md new file mode 100644 index 0000000..fca0f02 --- /dev/null +++ b/工程分析/实现方案-2026-05-08-02-36-12.md @@ -0,0 +1,65 @@ +# 实现方案 + +开始时间:2026-05-08-02-36-12 + +## 本次方案路径 + +`工程分析/实现方案-2026-05-08-02-36-12.md` + +## 实现目标 + +在 DICOM 阅览中实现真实 STL 模型切分 mask,并用单个双端点进度条控制起点/终点帧。 + +## 涉及文件 + +- `web_backend.py` +- `WebSite/src/App.tsx` +- `工程分析/经验记录.md` + +## 执行步骤 + +1. 后端新增 STL 模型目录和缓存: + - `MODEL_DIR = web_library/_stl_models` + - `STL_MODEL_CACHE` + - 支持 ASCII STL 和 binary STL 解析为三角面数组。 +2. 后端新增 STL 上传接口: + - `POST /api/model/upload` + - 使用请求体保存 STL 文件。 + - 返回 `modelId`、`name`、`triangleCount`。 +3. 后端新增 DICOM 几何元数据提取: + - 基于排序后的 DICOM 文件读取 `ImagePositionPatient`、`ImageOrientationPatient`、`PixelSpacing`。 + - 构造 patient 坐标到体素坐标的转换。 + - 元数据不足时降级为 STL 已是体素坐标。 +4. 后端增强 `/api/library/reformat-preview`: + - 支持 `modelId` 参数。 + - 当传入模型时,根据 `plane` 和 `index` 计算 STL 三角面与当前 DICOM 切片平面的交线。 + - 将交线栅格化并填充形成 mask,叠加到 DICOM PNG 上。 + - 返回 `maskPixels`,便于前端知道该帧是否有模型穿透。 +5. 前端 DICOM 阅览新增模型切分控制: + - STL 文件上传按钮。 + - 模型切分开关。 + - 一个双端点进度条控制起点/终点,两个端点允许交叉。 + - 起点/终点数值按当前切片总数约束。 +6. 前端模型切分展示: + - 模型切分关闭:保留当前单帧 DICOM 阅览。 + - 模型切分开启:显示起点帧、终点帧两张 DICOM 图,并请求后端叠加真实 STL mask。 + - 不再显示无意义的 CT MASK 图片或伪造圆圈 mask。 +7. 执行测试方案。 +8. 更新 `工程分析/经验记录.md`。 +9. 提交并推送 Gitea,commit 信息使用 `2026-05-08-02-36-12 实现STL模型切分mask`。 +10. 重新部署到 `http://192.168.3.11:3005/`。 + +## 回滚思路 + +若 STL mask 功能不符合预期,可回滚 `web_backend.py` 中 STL 上传/解析/mask 叠加逻辑,以及 `WebSite/src/App.tsx` 中模型切分 UI,恢复纯 DICOM 阅览。 + +## 风险控制 + +- STL 解析结果和 DICOM 体数据都使用缓存,避免高频重复解析。 +- mask 叠加只影响阅览 PNG,不修改原始 DICOM 和形变输出。 +- 双端点进度条不影响普通 DICOM 阅览切片滑杆。 +- 若 STL 与 DICOM 空间不匹配,前端仍显示 DICOM 切片,并以 `maskPixels=0` 表示该帧无交集。 + +## 人工审核状态 + +用户已明确本次不需要人工二次确认,直接执行。 diff --git a/工程分析/测试方案-2026-05-08-02-36-12.md b/工程分析/测试方案-2026-05-08-02-36-12.md new file mode 100644 index 0000000..64a6f74 --- /dev/null +++ b/工程分析/测试方案-2026-05-08-02-36-12.md @@ -0,0 +1,94 @@ +# 测试方案 + +开始时间:2026-05-08-02-36-12 + +## 本次方案路径 + +`工程分析/测试方案-2026-05-08-02-36-12.md` + +## 测试范围 + +- STL 上传接口。 +- ASCII STL 和 binary STL 解析。 +- STL 与 DICOM 切片相交 mask 生成。 +- 模型切分开启后起点帧/终点帧双图显示。 +- 双端点进度条起点/终点可交叉。 +- 普通 DICOM 阅览不受影响。 +- 前端类型检查、构建和 Python 语法检查。 +- 重新部署后访问验证。 + +## 测试命令 + +Python 语法检查: + +```bash +python -m py_compile web_backend.py head_extension_app.py +``` + +前端类型检查: + +```bash +cd WebSite +npm run lint +``` + +前端构建: + +```bash +cd WebSite +npm run build +``` + +后端函数级测试建议: + +```bash +python - <<'PY' +from web_backend import parse_stl_bytes +sample = b'''solid demo +facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 10 0 0 + vertex 0 10 0 + endloop +endfacet +endsolid demo +''' +triangles = parse_stl_bytes(sample) +print(triangles.shape) +PY +``` + +部署验证: + +```bash +curl -I --max-time 5 http://192.168.3.11:3005/ +curl -s --max-time 10 "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=256&window=bone" +``` + +## 手工验证点 + +- 打开 DICOM 阅览,普通冠状位/矢状位切片仍可查看。 +- 上传 STL 后启用模型切分。 +- 使用一个双端点进度条调节起点/终点帧。 +- 将起点拖过终点或终点拖过起点后,界面仍能按两个端点显示切分结果。 +- 起点帧和终点帧上显示真实 STL 切面 mask;无交集时不显示假 mask。 +- 不再出现单独无意义的 CT MASK 图片。 + +## 验收标准 + +- STL 上传返回模型信息。 +- 带 `modelId` 的 `/api/library/reformat-preview` 可返回叠加 mask 的 PNG。 +- `python -m py_compile web_backend.py head_extension_app.py` 通过。 +- `npm run lint` 通过。 +- `npm run build` 通过。 +- Gitea commit/push 完成。 +- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`。 + +## 残余风险 + +- mask 是否精确覆盖目标结构取决于 STL 与 DICOM 是否同坐标系、同单位、同方向。如果 STL 未与 DICOM patient 坐标配准,需要额外提供配准矩阵或模型平移/缩放/旋转控制。 + +## 人工审核状态 + +用户已明确本次不需要人工二次确认,直接执行。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index c9dd0e7..e929e88 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -73,3 +73,21 @@ C. 解决问题方案 D. 后续如何避免问题 涉及整套 DICOM 体数据读取的接口必须考虑缓存、并发和请求节流,不能把滑杆这类高频 UI 操作直接绑定到重型后端计算。自动下载类逻辑必须记录已处理 job,避免同一个完成状态在后续渲染或轮询中重复触发。 + +## 2026-05-08-02-36-12 实现 STL 模型切分 mask + +A. 具体问题 + +用户要求模型切分不能再用象征性画圈或无意义 CT MASK 图,而要根据 STL 模型真实穿透 DICOM 的位置生成 mask,并在 DICOM 起点帧、终点帧上显示。 + +B. 产生问题原因 + +当前仓库主线没有 STL 模型切分实现,只有 DICOM 冠状/矢状阅览。若用前端绘制圆圈或固定区域替代,会与 STL 几何和 DICOM 切片没有真实关系,无法表达语义分割切片形态。 + +C. 解决问题方案 + +后端新增 STL 上传、ASCII/Binary STL 解析、DICOM patient 坐标到体素坐标转换、STL 三角面与冠状/矢状切片平面求交、交线栅格化和填充形成 mask,并叠加到 DICOM 阅览 PNG。前端新增模型切分开关、STL 上传、单条双端点进度条,以及起点帧/终点帧双图 mask 展示。 + +D. 后续如何避免问题 + +涉及医学影像与模型叠加时,mask 必须来自真实几何或分割数据,不能用装饰性形状替代。若 STL 与 DICOM 坐标系不一致,应优先补充配准矩阵或平移/旋转/缩放参数,而不是在图像上手工假标。 diff --git a/工程分析/需求分析-2026-05-08-02-36-12.md b/工程分析/需求分析-2026-05-08-02-36-12.md new file mode 100644 index 0000000..b6d8aaa --- /dev/null +++ b/工程分析/需求分析-2026-05-08-02-36-12.md @@ -0,0 +1,53 @@ +# 需求分析 + +开始时间:2026-05-08-02-36-12 + +## 原始需求 + +用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改都不需要人工二次确认。 + +本次具体需求: + +1. 起点、终点合并到一个进度条里,进度条有两个端点,起点和终点可以调整顺序。 +2. 模型切分启用后,不能只是象征性画圈,而要真正判断 STL 模型穿透 DICOM 的位置,并用颜色标出形成 mask。 +3. 模型切分下方的帧进度栏没有实际意义。模型切分开启后,不需要在最后一张显示 CT,也不需要当前无意义的 CT MASK 图片;应像之前一样用 DICOM 起点帧、终点帧对模型切两刀,并在这两个特定帧上显示 mask。这个 mask 代表重建 STL 模型原始语义分割的大致切片形态。 + +## 目标 + +- 在 DICOM 阅览中新增真实 STL 模型切分能力。 +- 上传 STL 后,后端解析 STL 三角面片,按当前 DICOM 平面和起点/终点帧计算切片相交区域。 +- 模型切分开启时,前端显示起点帧和终点帧两张 DICOM 切片,并叠加真实 STL 切面 mask。 +- 用一个双端点进度条控制起点和终点,允许两个端点交叉,交叉后按数值顺序用于切分。 +- 删除/不再显示无意义的 CT MASK 图片或伪 mask。 + +## 影响范围 + +- `web_backend.py` + - STL 上传接口。 + - STL 解析、缓存。 + - DICOM 阅览切片接口叠加真实 STL 切面 mask。 +- `WebSite/src/App.tsx` + - DICOM 阅览弹层新增 STL 上传、模型切分开关、双端点进度条和双帧 mask 展示。 + - 移除模型切分状态下无意义的单帧 CT/MASK 展示。 +- `工程分析/经验记录.md` + +## 当前定位 + +当前仓库主线中没有已有 `STL/模型切分/MASK` 代码,只有 DICOM 阅览和冠状/矢状重建预览。因此本次不是修补已有“画圈”代码,而是在现有 DICOM 阅览里补上真实 STL 切片 mask 能力。 + +## 约束 + +- 不引入大型前端 3D 库。 +- 不改变真实 DICOM 形变算法。 +- 不提交 STL、DICOM、mask 缓存图片、ZIP 或构建产物。 +- STL 与 DICOM 的空间配准优先按 DICOM `ImagePositionPatient`、`ImageOrientationPatient`、`PixelSpacing` 转换;若元数据不足,则降级假设 STL 坐标已在体素坐标系中。 + +## 风险点 + +- STL 与 DICOM 是否同一坐标系直接决定 mask 是否对齐;若输入 STL 未配准到 DICOM patient 坐标,mask 位置仍会偏。 +- 不同 STL 拓扑可能导致切面轮廓不闭合,mask 填充可能只显示轮廓或局部区域。 +- 大 STL 模型解析和多切片 mask 计算可能耗时,需要缓存解析后的三角面。 + +## 待确认事项 + +用户已明确本次不需要二次人工确认,因此文档写完后直接执行。