- {[
- { 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 可能为空;前端需给出清晰状态。
+
+## 待确认事项
+
+- 无需等待二次确认;按用户此前授权直接执行。