From 946c0f4ef3d85dcb91863b1da5b8ab36a72794f3 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 8 May 2026 03:49:47 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-08-03-45-01=20=E8=B0=83=E6=95=B4Mask?= =?UTF-8?q?=E5=8F=8C=E5=9B=BE=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 | 67 ++++++++++++++---------- web_backend.py | 33 +++++++++--- 工程分析/实现方案-2026-05-08-03-45-01.md | 43 +++++++++++++++ 工程分析/测试方案-2026-05-08-03-45-01.md | 50 ++++++++++++++++++ 工程分析/经验记录.md | 18 +++++++ 工程分析/需求分析-2026-05-08-03-45-01.md | 38 ++++++++++++++ 6 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 工程分析/实现方案-2026-05-08-03-45-01.md create mode 100644 工程分析/测试方案-2026-05-08-03-45-01.md create mode 100644 工程分析/需求分析-2026-05-08-03-45-01.md diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index dbb6e0c..9c17cae 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -1015,7 +1015,7 @@ export default function App() { 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)}` + `${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${index}&window=${encodeURIComponent(viewerWindow)}&modelId=${encodeURIComponent(stlModel.modelId)}&maskOnly=1` ); setIsModelMaskLoading(true); @@ -2230,33 +2230,46 @@ export default function App() {
{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` : '无交集'} -

-
+
+
+
+

Mask 展示

+

+ {VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label} · {VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label} +

- ))} + + {Math.min(clampedModelStart, clampedModelEnd) + 1} - {Math.max(clampedModelStart, clampedModelEnd) + 1} + +
+
+ {[ + { 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 ? ( diff --git a/web_backend.py b/web_backend.py index b6d7c67..090570c 100644 --- a/web_backend.py +++ b/web_backend.py @@ -499,7 +499,20 @@ def make_library_slice_preview(item_id, index): } -def make_library_reformat_preview(item_id, plane, index, window, model_id=""): +def render_mask_only_preview(mask, size): + preview = Image.new("RGB", size, (8, 13, 28)) + if mask is None: + return preview + + alpha = mask.resize(size, Image.Resampling.NEAREST) + overlay = Image.new("RGBA", size, (255, 112, 32, 0)) + overlay.putalpha(alpha.point(lambda value: 210 if value else 0)) + preview_rgba = preview.convert("RGBA") + preview_rgba.alpha_composite(overlay) + return preview_rgba.convert("RGB") + + +def make_library_reformat_preview(item_id, plane, index, window, model_id="", mask_only=False): item = find_library_item(item_id) if not item: raise RuntimeError("影像库中没有找到该数据。") @@ -537,13 +550,18 @@ def make_library_reformat_preview(item_id, plane, index, window, model_id=""): cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat" safe_mkdir(cache_dir) model_suffix = f"_model_{safe_filename(model_id)}" if model_id else "" - preview_path = cache_dir / f"{plane}_{window}_{index:04d}{model_suffix}.png" + view_suffix = "_mask_only" if mask_only and model_id else "" + preview_path = cache_dir / f"{plane}_{window}_{index:04d}{model_suffix}{view_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) + if mask_only and model_id: + base_size = fit_image(Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB"), 960, 720).size + preview = render_mask_only_preview(mask, base_size) + else: + 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") return { @@ -1234,7 +1252,8 @@ class Handler(BaseHTTPRequestHandler): index = params.get("index", ["0"])[0] window = params.get("window", ["default"])[0] model_id = params.get("modelId", [""])[0] - self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id)) + mask_only = params.get("maskOnly", ["0"])[0] in {"1", "true", "yes"} + self.send_json(make_library_reformat_preview(item_id, plane, index, window, model_id, mask_only)) return if parsed.path == "/api/library/info": diff --git a/工程分析/实现方案-2026-05-08-03-45-01.md b/工程分析/实现方案-2026-05-08-03-45-01.md new file mode 100644 index 0000000..06f17ad --- /dev/null +++ b/工程分析/实现方案-2026-05-08-03-45-01.md @@ -0,0 +1,43 @@ +# 实现方案 - 2026-05-08-03-45-01 + +## 方案路径 + +围绕现有 DICOM 阅览弹窗调整右侧展示逻辑:普通阅览状态继续显示当前 DICOM 图;点击“模型切分”且 STL 已载入后,右侧区域切换为“Mask 展示”,固定展示起点帧和终点帧两张由后端真实 STL mask 生成的图片。 + +## 涉及文件 + +- `WebSite/src/App.tsx` + - 优化模型切分启用时的右侧 Mask 展示 UI。 + - 明确展示标题、起点帧/终点帧两张图片和各自帧号。 + - 避免在 mask 展示中混入单张普通 DICOM 预览。 +- `web_backend.py` + - 在已有重建预览接口中增加 `maskOnly` 输出模式,用同一 STL/DICOM 切面 mask 生成纯 mask 图片。 +- `工程分析/经验记录.md` + - 追加本轮关键问题和解决方案。 + +## 执行步骤 + +1. 确认现有 `modelStartPreview` 与 `modelEndPreview` 已通过 `modelId` 请求后端真实 mask 图。 +2. 调整后端 `/api/library/reformat-preview`: + - 增加 `maskOnly=1` 参数。 + - 参数存在且带有 `modelId` 时,输出 STL 切面 mask-only 图片,而不是普通 CT 叠加图。 + - 缓存文件名区分 mask-only,避免复用旧 CT 预览缓存。 +3. 调整 `App.tsx` 中右侧预览区域: + - 模型切分启用且 STL 存在时,显示 Mask 展示头部。 + - Mask 展示内容固定为起点帧、终点帧两张图片。 + - 两张图片均使用当前平面、窗宽窗位和范围端点请求结果。 + - 空交集或加载失败时,在对应图片区域显示状态。 +4. 保持未启用模型切分时的普通 DICOM 阅览图不受影响。 +5. 运行前端类型检查、构建和后端语法检查。 +6. 重启后端与前端服务,验证部署地址可访问。 +7. 更新经验记录,提交并推送 Gitea,commit 信息使用 `2026-05-08-03-45-01 调整Mask双图展示`。 + +## 回滚思路 + +若展示逻辑异常,可回滚 `WebSite/src/App.tsx` 中右侧预览区域的 JSX 调整,恢复此前模型切分条件渲染逻辑。 + +## 风险控制 + +- 不改 STL 解析和 DICOM mask 计算核心算法,降低医学影像处理链路风险。 +- 保留普通 DICOM 阅览路径,避免影响原有冠状位/矢状位查看。 +- 使用现有 `modelStartPreview`、`modelEndPreview` 状态,避免新增并发请求链路。 diff --git a/工程分析/测试方案-2026-05-08-03-45-01.md b/工程分析/测试方案-2026-05-08-03-45-01.md new file mode 100644 index 0000000..90f3f3d --- /dev/null +++ b/工程分析/测试方案-2026-05-08-03-45-01.md @@ -0,0 +1,50 @@ +# 测试方案 - 2026-05-08-03-45-01 + +## 测试范围 + +- 前端类型检查。 +- 前端生产构建。 +- 后端 Python 语法检查。 +- 模型切分启用/关闭时右侧预览区域的条件渲染。 +- 起点帧、终点帧两张 mask 图片路径是否仍来自后端 `modelId` 请求结果。 + +## 测试命令 + +```bash +cd WebSite +npm run lint +npm run build +``` + +```bash +python -m py_compile web_backend.py +``` + +部署检查: + +```bash +cd WebSite +npm run backend +npm run dev +``` + +或按当前运行方式重启对应服务后访问 `http://192.168.3.11:3005`。 + +## 手工验证点 + +- 打开 DICOM 阅览,未启用模型切分时,右侧仍显示普通 DICOM 预览图。 +- 上传 STL 并点击“模型切分”后,右侧显示 Mask 展示。 +- Mask 展示中有且只有两张图片:起点帧、终点帧。 +- 调整范围端点后,两张图片对应帧号和图像刷新。 +- 切换冠状位/矢状位或显示模式后,两张 mask 图片刷新。 + +## 验收标准 + +- 模型切分启用后,右侧不再表现为单张 CT mask 或交互式模型视图,而是两张静态 mask-only 图片。 +- 两张图片分别对应 DICOM 起点帧和终点帧的 STL 切面 mask。 +- `npm run lint` 和 `npm run build` 通过。 +- 项目重新部署后页面可访问。 + +## 无法测试的风险 + +- 若当前环境缺少可用于手工验证的 STL 与 DICOM 数据,只能通过类型检查、构建和接口逻辑验证保证基本正确性。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 79a19b8..ffa20d6 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -109,3 +109,21 @@ C. 解决问题方案 D. 后续如何避免问题 实现双端范围选择器时,不要直接依赖两个原生 range 的默认轨道。应使用自定义轨道绘制范围,仅让原生 input 提供拖拽能力,或引入明确支持 range selection 的组件。 + +## 2026-05-08-03-45-01 调整 Mask 双图展示 + +A. 具体问题 + +用户强调逆向工作区右侧“Mask 展示”应在点击“模型切分”后显示,并且展示前后两张由 DICOM 切分 STL 模型得到的图片,而不是单张 CT mask、交互式模型视图或混杂普通 DICOM 阅览结果。 + +B. 产生问题原因 + +此前模型切分展示复用了 DICOM 重建预览图叠加 mask 的接口语义,虽然已有起点帧和终点帧两张图,但视觉上仍容易被理解为 CT 图叠加结果,没有把“模型切面 mask 图片”与普通 DICOM 阅览明确区分。 + +C. 解决问题方案 + +在后端重建预览接口增加 `maskOnly=1` 输出模式,复用真实 STL/DICOM 切面 mask 计算结果生成纯 mask 图片,并用独立缓存后缀避免复用普通 CT 预览。前端在模型切分启用且 STL 已载入时,将右侧区域切换为明确的“Mask 展示”双图布局,分别展示起点帧和终点帧。 + +D. 后续如何避免问题 + +涉及 mask 或语义分割结果展示时,应区分“CT 背景叠加图”和“mask-only 结果图”。如果用户要求展示模型切面或分割形态,优先提供独立 mask 图片,并在 UI 上明确区分普通阅览与切分结果。 diff --git a/工程分析/需求分析-2026-05-08-03-45-01.md b/工程分析/需求分析-2026-05-08-03-45-01.md new file mode 100644 index 0000000..b45d888 --- /dev/null +++ b/工程分析/需求分析-2026-05-08-03-45-01.md @@ -0,0 +1,38 @@ +# 需求分析 - 2026-05-08-03-45-01 + +## 原始需求 + +用户要求:逆向工作区右侧“Mask 展示”应在点击“模型切分”后显示,内容是前后两张 DICOM 切分出的模型样子,且明确应为两张图片。 + +## 目标 + +- 模型切分未启用时,不展示 STL mask 双图结果。 +- 模型切分启用且存在 STL 模型时,右侧展示起点帧、终点帧两张图片。 +- 两张图片应来自当前 DICOM 平面、显示模式、切片范围端点和 STL 模型的真实切面 mask 结果。 +- 保持普通 DICOM 阅览在未启用模型切分时可正常使用。 + +## 影响范围 + +- `WebSite/src/App.tsx` + - DICOM 阅览弹窗右侧预览区域。 + - 模型切分状态、起点帧/终点帧 mask 预览显示。 +- `web_backend.py` + - 复核现有 `/api/library/reformat-preview` 是否已经支持 `modelId` 生成 STL mask 叠加图。 + +## 约束 + +- 严格使用本仓库代码编纂工作流。 +- 本轮使用同一开始时间戳 `2026-05-08-03-45-01`。 +- 本次按用户此前说明,需求分析、实现方案、测试方案和执行修改不再等待二次人工确认。 +- 不得用象征性图形替代真实 STL/DICOM 切面关系。 +- 不得提交 Gitea 密码、令牌或其他凭据。 + +## 风险点 + +- 若只改前端文案但仍展示单图或普通 DICOM 图,用户会误认为 mask 展示没有按切分结果变化。 +- 若后端缓存文件名未区分模型、平面、窗宽窗位、切片索引,可能显示旧图。 +- STL 与 DICOM 坐标系不一致时,mask 可能为空;前端需给出清晰状态。 + +## 待确认事项 + +- 无需等待二次确认;按用户此前授权直接执行。