2026-05-08-03-45-01 调整Mask双图展示
This commit is contained in:
@@ -1015,7 +1015,7 @@ export default function App() {
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const makeUrl = (index: number) => (
|
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);
|
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">
|
<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 ? (
|
{isModelSlicingEnabled && stlModel ? (
|
||||||
<div className="w-full h-full grid grid-cols-1 xl:grid-cols-2 gap-0">
|
<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">
|
||||||
{ label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' },
|
<div>
|
||||||
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Mask 展示</p>
|
||||||
].map(item => (
|
<p className="text-[10px] font-bold text-white/55 mt-1">
|
||||||
<div key={item.label} className="relative min-h-[330px] flex items-center justify-center border-slate-800 xl:border-l first:border-l-0">
|
{VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label} · {VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label}
|
||||||
{item.preview?.imageUrl && !modelMaskError ? (
|
</p>
|
||||||
<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>
|
||||||
))}
|
<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>
|
</div>
|
||||||
) : viewerPreview?.imageUrl && !viewerError ? (
|
) : viewerPreview?.imageUrl && !viewerError ? (
|
||||||
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
|
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
|
||||||
|
|||||||
@@ -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)
|
item = find_library_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise RuntimeError("影像库中没有找到该数据。")
|
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"
|
cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat"
|
||||||
safe_mkdir(cache_dir)
|
safe_mkdir(cache_dir)
|
||||||
model_suffix = f"_model_{safe_filename(model_id)}" if model_id else ""
|
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():
|
if not preview_path.exists():
|
||||||
preset = VIEWER_WINDOWS[window]
|
preset = VIEWER_WINDOWS[window]
|
||||||
preview = Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB")
|
if mask_only and model_id:
|
||||||
if mask is not None:
|
base_size = fit_image(Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB"), 960, 720).size
|
||||||
preview = overlay_mask_on_preview(preview, mask)
|
preview = render_mask_only_preview(mask, base_size)
|
||||||
preview = fit_image(preview, 960, 720)
|
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")
|
preview.save(preview_path, format="PNG")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1234,7 +1252,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
index = params.get("index", ["0"])[0]
|
index = params.get("index", ["0"])[0]
|
||||||
window = params.get("window", ["default"])[0]
|
window = params.get("window", ["default"])[0]
|
||||||
model_id = params.get("modelId", [""])[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
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/library/info":
|
if parsed.path == "/api/library/info":
|
||||||
|
|||||||
43
工程分析/实现方案-2026-05-08-03-45-01.md
Normal file
43
工程分析/实现方案-2026-05-08-03-45-01.md
Normal 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. 更新经验记录,提交并推送 Gitea,commit 信息使用 `2026-05-08-03-45-01 调整Mask双图展示`。
|
||||||
|
|
||||||
|
## 回滚思路
|
||||||
|
|
||||||
|
若展示逻辑异常,可回滚 `WebSite/src/App.tsx` 中右侧预览区域的 JSX 调整,恢复此前模型切分条件渲染逻辑。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 不改 STL 解析和 DICOM mask 计算核心算法,降低医学影像处理链路风险。
|
||||||
|
- 保留普通 DICOM 阅览路径,避免影响原有冠状位/矢状位查看。
|
||||||
|
- 使用现有 `modelStartPreview`、`modelEndPreview` 状态,避免新增并发请求链路。
|
||||||
50
工程分析/测试方案-2026-05-08-03-45-01.md
Normal file
50
工程分析/测试方案-2026-05-08-03-45-01.md
Normal 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 数据,只能通过类型检查、构建和接口逻辑验证保证基本正确性。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -109,3 +109,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
实现双端范围选择器时,不要直接依赖两个原生 range 的默认轨道。应使用自定义轨道绘制范围,仅让原生 input 提供拖拽能力,或引入明确支持 range selection 的组件。
|
实现双端范围选择器时,不要直接依赖两个原生 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 上明确区分普通阅览与切分结果。
|
||||||
|
|||||||
38
工程分析/需求分析-2026-05-08-03-45-01.md
Normal file
38
工程分析/需求分析-2026-05-08-03-45-01.md
Normal 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 可能为空;前端需给出清晰状态。
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
- 无需等待二次确认;按用户此前授权直接执行。
|
||||||
Reference in New Issue
Block a user