2026-05-08-03-45-01 调整Mask双图展示

This commit is contained in:
2026-05-08 03:49:47 +08:00
parent aee4466a94
commit 946c0f4ef3
6 changed files with 215 additions and 34 deletions

View File

@@ -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() {
<div className="min-h-[360px] lg:min-h-[560px] bg-slate-950 rounded-2xl border border-slate-900 overflow-hidden flex items-center justify-center relative">
{isModelSlicingEnabled && stlModel ? (
<div className="w-full h-full grid grid-cols-1 xl:grid-cols-2 gap-0">
{[
{ label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' },
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
].map(item => (
<div key={item.label} className="relative min-h-[330px] flex items-center justify-center border-slate-800 xl:border-l first:border-l-0">
{item.preview?.imageUrl && !modelMaskError ? (
<img src={`${API_BASE}${item.preview.imageUrl}`} className="w-full h-full object-contain" />
) : (
<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>
</div>
)}
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
<p className={`text-[10px] font-black ${item.color}`}>{item.label}</p>
<p className="text-[10px] font-mono text-white/70 mt-0.5">
{item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'}
</p>
</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` : '无交集'}
</p>
</div>
<div className="w-full h-full flex flex-col">
<div className="px-5 py-4 border-b border-white/10 flex items-center justify-between gap-3">
<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}
</p>
</div>
))}
<span className="px-3 py-1.5 rounded-full bg-white/10 text-[10px] font-mono font-black text-white/70">
{Math.min(clampedModelStart, clampedModelEnd) + 1} - {Math.max(clampedModelStart, clampedModelEnd) + 1}
</span>
</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' },
].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 ? (
<img src={`${API_BASE}${item.preview.imageUrl}`} className="w-full h-full object-contain" />
) : (
<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>
</div>
)}
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
<p className={`text-[10px] font-black ${item.color}`}>{item.label}</p>
<p className="text-[10px] font-mono text-white/70 mt-0.5">
{item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'}
</p>
</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` : '无交集'}
</p>
</div>
</div>
))}
</div>
</div>
) : viewerPreview?.imageUrl && !viewerError ? (
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />

View File

@@ -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":

View File

@@ -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. 更新经验记录,提交并推送 Giteacommit 信息使用 `2026-05-08-03-45-01 调整Mask双图展示`
## 回滚思路
若展示逻辑异常,可回滚 `WebSite/src/App.tsx` 中右侧预览区域的 JSX 调整,恢复此前模型切分条件渲染逻辑。
## 风险控制
- 不改 STL 解析和 DICOM mask 计算核心算法,降低医学影像处理链路风险。
- 保留普通 DICOM 阅览路径,避免影响原有冠状位/矢状位查看。
- 使用现有 `modelStartPreview``modelEndPreview` 状态,避免新增并发请求链路。

View File

@@ -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 数据,只能通过类型检查、构建和接口逻辑验证保证基本正确性。

View File

@@ -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 上明确区分普通阅览与切分结果。

View File

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