Files
Voxelmorph_Head_CT/app.py

459 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Streamlit 交互式结果展示界面。
运行:
streamlit run app.py
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List, Sequence, Tuple
import matplotlib.pyplot as plt
import numpy as np
import streamlit as st
from config import (
CHECKPOINT_DIR,
DEFAULT_CHECKPOINT,
DEFAULT_FIXED_NIFTI,
DEFAULT_MOVING_NIFTI,
INFERENCE_DIR,
OUTPUT_ROOT,
PROJECT_ROOT,
)
from metrics import crop_to_common_shape, ddf_summary, registration_metrics, slice_metric_curve
st.set_page_config(
page_title="VoxelMorph 颈部 CT 配准工作台",
layout="wide",
initial_sidebar_state="expanded",
)
CUSTOM_CSS = """
<style>
:root {
--ink: #202124;
--muted: #5f6368;
--line: #d8dee4;
--panel: #f6f8fa;
--teal: #0f766e;
--amber: #b45309;
}
.main .block-container {
padding-top: 1.1rem;
padding-bottom: 2rem;
max-width: 1480px;
}
h1, h2, h3 {
letter-spacing: 0;
}
div[data-testid="stMetric"] {
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: #ffffff;
}
section[data-testid="stSidebar"] {
border-right: 1px solid var(--line);
}
.stTabs [data-baseweb="tab-list"] {
gap: 8px;
border-bottom: 1px solid var(--line);
}
.stTabs [data-baseweb="tab"] {
border-radius: 0;
padding-left: 4px;
padding-right: 4px;
}
</style>
"""
def _require_nibabel():
try:
import nibabel as nib # type: ignore
except Exception as exc:
raise RuntimeError("缺少 nibabel请先运行: pip install -r requirements.txt") from exc
return nib
def discover_nifti_files() -> List[str]:
roots = [OUTPUT_ROOT, PROJECT_ROOT]
paths: List[Path] = []
for root in roots:
if root.exists():
paths.extend(root.rglob("*.nii"))
paths.extend(root.rglob("*.nii.gz"))
unique = sorted({str(path) for path in paths})
return unique
def discover_checkpoint_files() -> List[str]:
roots = [CHECKPOINT_DIR, OUTPUT_ROOT, PROJECT_ROOT]
paths: List[Path] = []
for root in roots:
if root.exists():
paths.extend(root.rglob("*.pt"))
paths.extend(root.rglob("*.pth"))
paths.extend(root.rglob("*.ckpt"))
return sorted({str(path) for path in paths})
def choose_path(label: str, default_path: Path, candidates: Sequence[str]) -> str:
options = ["手动输入"] + list(candidates)
default_str = str(default_path)
selected_index = options.index(default_str) if default_str in options else (1 if candidates else 0)
selected = st.selectbox(label, options=options, index=selected_index)
if selected == "手动输入":
return st.text_input(f"{label}路径", value=default_str)
return selected
@st.cache_data(max_entries=8, show_spinner=False)
def load_nifti_cached(path: str, max_voxels: int = 14_000_000) -> Tuple[np.ndarray, Tuple[float, float, float], int]:
"""为 Web 展示读取 NIfTI。
Streamlit/浏览器端不适合一次渲染超大体数据。如果体素数过大,按等比例
stride 下采样,只影响网页查看,不改变磁盘上的推理结果。
"""
if not path:
raise ValueError("路径为空。")
nib = _require_nibabel()
img = nib.load(path, mmap=True)
if len(img.shape) > 4:
raise ValueError(f"暂不支持超过 4D 的 NIfTI: {path}")
spatial_shape = img.shape[:3]
voxels = int(np.prod(spatial_shape))
stride = max(1, int(np.ceil((voxels / max_voxels) ** (1.0 / 3.0)))) if voxels > max_voxels else 1
spatial_slices = (slice(None, None, stride), slice(None, None, stride), slice(None, None, stride))
if len(img.shape) == 4:
data = np.asarray(img.dataobj[spatial_slices + (slice(None),)], dtype=np.float32)
else:
data = np.asarray(img.dataobj[spatial_slices], dtype=np.float32)
data = np.nan_to_num(data, copy=False)
spacing = tuple(float(v) * stride for v in img.header.get_zooms()[:3])
return data, spacing, stride # type: ignore[return-value]
def normalize_slice(image: np.ndarray, p_low: float = 1.0, p_high: float = 99.0) -> np.ndarray:
image = image.astype(np.float32, copy=False)
low, high = np.percentile(image, [p_low, p_high])
if high <= low:
return np.zeros_like(image, dtype=np.float32)
return np.clip((image - low) / (high - low), 0.0, 1.0)
def orient_for_display(image: np.ndarray) -> np.ndarray:
"""统一显示方向,让三视图在网页中更接近医学浏览器观感。"""
return np.rot90(image)
def get_slice(volume_xyz: np.ndarray, plane: str, index: int) -> np.ndarray:
if plane == "Axial":
return volume_xyz[:, :, index]
if plane == "Coronal":
return volume_xyz[:, index, :]
if plane == "Sagittal":
return volume_xyz[index, :, :]
raise ValueError(f"未知平面: {plane}")
def plane_axis(plane: str) -> int:
return {"Sagittal": 0, "Coronal": 1, "Axial": 2}[plane]
def checkerboard(fixed_slice: np.ndarray, warped_slice: np.ndarray, tile: int = 24) -> np.ndarray:
fixed_norm = normalize_slice(fixed_slice)
warped_norm = normalize_slice(warped_slice)
yy, xx = np.indices(fixed_norm.shape)
mask = ((yy // tile + xx // tile) % 2).astype(bool)
return np.where(mask, fixed_norm, warped_norm)
def alpha_overlay(fixed_slice: np.ndarray, warped_slice: np.ndarray, alpha: float = 0.45) -> np.ndarray:
fixed_norm = normalize_slice(fixed_slice)
warped_norm = normalize_slice(warped_slice)
rgb = np.zeros((*fixed_norm.shape, 3), dtype=np.float32)
rgb[..., 0] = warped_norm
rgb[..., 1] = fixed_norm * (1.0 - alpha) + warped_norm * alpha
rgb[..., 2] = fixed_norm
return np.clip(rgb, 0.0, 1.0)
def ddf_magnitude(ddf_xyz: np.ndarray) -> np.ndarray:
if ddf_xyz.ndim != 4 or ddf_xyz.shape[-1] != 3:
raise ValueError("DDF 应为 (X, Y, Z, 3)。")
return np.linalg.norm(ddf_xyz.astype(np.float32, copy=False), axis=-1)
def render_image(image: np.ndarray, caption: str, cmap: str = "gray") -> None:
fig, ax = plt.subplots(figsize=(4.8, 4.8), dpi=120)
ax.imshow(orient_for_display(image), cmap=cmap, interpolation="nearest")
ax.set_title(caption, fontsize=10)
ax.axis("off")
st.pyplot(fig, use_container_width=True)
plt.close(fig)
def render_rgb(image: np.ndarray, caption: str) -> None:
fig, ax = plt.subplots(figsize=(4.8, 4.8), dpi=120)
ax.imshow(orient_for_display(image), interpolation="nearest")
ax.set_title(caption, fontsize=10)
ax.axis("off")
st.pyplot(fig, use_container_width=True)
plt.close(fig)
def render_heatmap_overlay(base_slice: np.ndarray, mag_slice: np.ndarray, alpha: float, caption: str) -> None:
fig, ax = plt.subplots(figsize=(4.8, 4.8), dpi=120)
ax.imshow(orient_for_display(normalize_slice(base_slice)), cmap="gray", interpolation="nearest")
heat = orient_for_display(mag_slice)
im = ax.imshow(heat, cmap="inferno", alpha=alpha, interpolation="nearest")
ax.set_title(caption, fontsize=10)
ax.axis("off")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
st.pyplot(fig, use_container_width=True)
plt.close(fig)
def render_three_views(volume_xyz: np.ndarray, title: str) -> None:
st.subheader(title)
columns = st.columns(3)
planes = [("Axial", "轴状面"), ("Coronal", "冠状面"), ("Sagittal", "矢状面")]
for col, (plane, cn_name) in zip(columns, planes):
axis = plane_axis(plane)
with col:
index = st.slider(f"{cn_name}", 0, volume_xyz.shape[axis] - 1, volume_xyz.shape[axis] // 2, key=f"{title}-{plane}")
render_image(get_slice(volume_xyz, plane, index), f"{cn_name} #{index}")
def render_overlay_views(fixed_xyz: np.ndarray, warped_xyz: np.ndarray) -> None:
fixed_xyz, warped_xyz = crop_to_common_shape(fixed_xyz, warped_xyz)
mode = st.radio("对比模式", ["Alpha 融合", "棋盘格"], horizontal=True)
alpha = st.slider("透明度", 0.0, 1.0, 0.45, 0.05)
tile = st.slider("棋盘格尺寸", 8, 64, 24, 4)
columns = st.columns(3)
planes = [("Axial", "轴状面"), ("Coronal", "冠状面"), ("Sagittal", "矢状面")]
for col, (plane, cn_name) in zip(columns, planes):
axis = plane_axis(plane)
with col:
index = st.slider(f"{cn_name}切片", 0, fixed_xyz.shape[axis] - 1, fixed_xyz.shape[axis] // 2, key=f"overlay-{plane}")
fixed_slice = get_slice(fixed_xyz, plane, index)
warped_slice = get_slice(warped_xyz, plane, index)
if mode == "棋盘格":
render_image(checkerboard(fixed_slice, warped_slice, tile=tile), f"{cn_name} 棋盘格 #{index}")
else:
render_rgb(alpha_overlay(fixed_slice, warped_slice, alpha=alpha), f"{cn_name} 融合 #{index}")
def render_ddf_views(fixed_xyz: np.ndarray, ddf_xyz: np.ndarray) -> None:
fixed_xyz, ddf_xyz = crop_to_common_shape(fixed_xyz, ddf_xyz)
mag = ddf_magnitude(ddf_xyz)
alpha = st.slider("热力图透明度", 0.0, 1.0, 0.55, 0.05)
stats = ddf_summary(ddf_xyz)
c1, c2, c3, c4 = st.columns(4)
c1.metric("平均位移 mm", f"{stats['ddf_mean']:.3f}")
c2.metric("P95 mm", f"{stats['ddf_p95']:.3f}")
c3.metric("最大 mm", f"{stats['ddf_max']:.3f}")
c4.metric("标准差", f"{stats['ddf_std']:.3f}")
columns = st.columns(3)
planes = [("Axial", "轴状面"), ("Coronal", "冠状面"), ("Sagittal", "矢状面")]
for col, (plane, cn_name) in zip(columns, planes):
axis = plane_axis(plane)
with col:
index = st.slider(f"{cn_name}DDF", 0, fixed_xyz.shape[axis] - 1, fixed_xyz.shape[axis] // 2, key=f"ddf-{plane}")
render_heatmap_overlay(
get_slice(fixed_xyz, plane, index),
get_slice(mag, plane, index),
alpha=alpha,
caption=f"{cn_name} 形变强度 #{index}",
)
def render_metric_charts(fixed_xyz: np.ndarray, moving_xyz: np.ndarray, warped_xyz: np.ndarray, ddf_xyz: np.ndarray | None) -> None:
fixed_xyz, moving_xyz, warped_xyz = crop_to_common_shape(fixed_xyz, moving_xyz, warped_xyz)
metrics = registration_metrics(fixed_xyz, moving_xyz, warped_xyz)
c1, c2, c3 = st.columns(3)
c1.metric("NCC", f"{metrics['after_ncc']:.4f}", delta=f"{metrics['ncc_improvement']:+.4f}")
c2.metric(
"MSE",
f"{metrics['after_mse']:.5f}",
delta=f"{metrics['after_mse'] - metrics['before_mse']:+.5f}",
delta_color="inverse",
)
c3.metric(
"MAE",
f"{metrics['after_mae']:.5f}",
delta=f"{metrics['after_mae'] - metrics['before_mae']:+.5f}",
delta_color="inverse",
)
labels = ["NCC", "MSE", "MAE"]
before = [metrics["before_ncc"], metrics["before_mse"], metrics["before_mae"]]
after = [metrics["after_ncc"], metrics["after_mse"], metrics["after_mae"]]
fig, axes = plt.subplots(1, 2, figsize=(10, 3.6), dpi=130)
x = np.arange(len(labels))
width = 0.36
axes[0].bar(x - width / 2, before, width, label="配准前", color="#64748b")
axes[0].bar(x + width / 2, after, width, label="配准后", color="#0f766e")
axes[0].set_xticks(x, labels)
axes[0].set_title("配准前后指标对比")
axes[0].legend(frameon=False)
axes[0].grid(axis="y", alpha=0.22)
curve = slice_metric_curve(fixed_xyz, moving_xyz, warped_xyz, axis=2)
axes[1].plot(curve["slice_index"], curve["before_mse"], label="配准前 MSE", color="#64748b", linewidth=1.8)
axes[1].plot(curve["slice_index"], curve["after_mse"], label="配准后 MSE", color="#b45309", linewidth=1.8)
axes[1].set_title("轴状面逐切片误差")
axes[1].set_xlabel("Slice")
axes[1].grid(alpha=0.22)
axes[1].legend(frameon=False)
st.pyplot(fig, use_container_width=True)
plt.close(fig)
if ddf_xyz is not None:
mag = ddf_magnitude(ddf_xyz)
fig2, ax = plt.subplots(figsize=(10, 3.2), dpi=130)
ax.hist(mag.ravel(), bins=80, color="#7c3f00", alpha=0.82)
ax.set_title("DDF 位移强度分布")
ax.set_xlabel("mm")
ax.set_ylabel("Voxel count")
ax.grid(axis="y", alpha=0.2)
st.pyplot(fig2, use_container_width=True)
plt.close(fig2)
def load_result_paths(out_dir: str) -> Dict[str, str]:
out = Path(out_dir)
return {
"warped": str(out / "warped_moving.nii.gz"),
"ddf": str(out / "ddf_mm.nii.gz"),
"metrics": str(out / "metrics.json"),
}
def run_inference_from_ui(moving_path: str, fixed_path: str, checkpoint_path: str, out_dir: str) -> Dict:
from infer import run_inference
return run_inference(
moving_path=moving_path,
fixed_path=fixed_path,
checkpoint_path=checkpoint_path,
out_dir=out_dir,
)
def main() -> None:
st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
st.title("VoxelMorph 颈部 CT 配准工作台")
candidates = discover_nifti_files()
checkpoint_candidates = discover_checkpoint_files()
with st.sidebar:
st.header("输入")
moving_path = choose_path("Moving", DEFAULT_MOVING_NIFTI, candidates)
fixed_path = choose_path("Fixed", DEFAULT_FIXED_NIFTI, candidates)
checkpoint_path = choose_path("模型权重", DEFAULT_CHECKPOINT, checkpoint_candidates)
out_dir = st.text_input("输出目录", value=str(INFERENCE_DIR))
display_max_voxels = st.slider("Web显示体素上限", 2_000_000, 30_000_000, 14_000_000, 1_000_000)
start = st.button("开始推理", type="primary", use_container_width=True)
if start:
with st.spinner("推理运行中"):
try:
load_nifti_cached.clear()
result = run_inference_from_ui(moving_path, fixed_path, checkpoint_path, out_dir)
load_nifti_cached.clear()
st.session_state["last_result"] = result
st.success("推理完成")
except Exception as exc:
st.error(str(exc))
result_paths = load_result_paths(out_dir)
last_result = st.session_state.get("last_result", {})
warped_path = str(last_result.get("warped_path", result_paths["warped"]))
ddf_path = str(last_result.get("ddf_path", result_paths["ddf"]))
metrics_path = str(last_result.get("metrics_path", result_paths["metrics"]))
status_cols = st.columns(4)
status_cols[0].metric("Moving", "存在" if Path(moving_path).exists() else "缺失")
status_cols[1].metric("Fixed", "存在" if Path(fixed_path).exists() else "缺失")
status_cols[2].metric("Warped", "存在" if Path(warped_path).exists() else "缺失")
status_cols[3].metric("DDF", "存在" if Path(ddf_path).exists() else "缺失")
try:
moving_xyz, moving_spacing, moving_stride = load_nifti_cached(moving_path, max_voxels=display_max_voxels)
fixed_xyz, fixed_spacing, fixed_stride = load_nifti_cached(fixed_path, max_voxels=display_max_voxels)
except Exception as exc:
st.warning(f"待显示数据尚未就绪: {exc}")
return
warped_xyz = None
ddf_xyz = None
if Path(warped_path).exists():
try:
warped_xyz, _, _ = load_nifti_cached(warped_path, max_voxels=display_max_voxels)
except Exception as exc:
st.warning(f"Warped 读取失败: {exc}")
if Path(ddf_path).exists():
try:
ddf_xyz, _, _ = load_nifti_cached(ddf_path, max_voxels=display_max_voxels)
except Exception as exc:
st.warning(f"DDF 读取失败: {exc}")
if moving_stride > 1 or fixed_stride > 1:
st.info(f"网页显示已自动下采样Moving stride={moving_stride}, Fixed stride={fixed_stride}")
tab_views, tab_overlay, tab_ddf, tab_metrics = st.tabs(["正交三视图", "重叠对比", "形变场", "量化图"])
with tab_views:
view_target = st.radio("显示对象", ["Fixed", "Moving", "Warped"], horizontal=True)
if view_target == "Fixed":
render_three_views(fixed_xyz, "Fixed Image")
elif view_target == "Moving":
render_three_views(moving_xyz, "Moving Image")
elif warped_xyz is not None:
render_three_views(warped_xyz, "Warped Image")
else:
st.warning("尚未生成 Warped Image。")
with tab_overlay:
if warped_xyz is None:
st.warning("尚未生成 Warped Image。")
else:
render_overlay_views(fixed_xyz, warped_xyz)
with tab_ddf:
if ddf_xyz is None:
st.warning("尚未生成 DDF。")
else:
render_ddf_views(fixed_xyz, ddf_xyz)
with tab_metrics:
if warped_xyz is None:
st.warning("尚未生成 Warped Image。")
else:
render_metric_charts(fixed_xyz, moving_xyz, warped_xyz, ddf_xyz)
metrics_file = Path(metrics_path)
if metrics_file.exists():
try:
st.json(json.loads(metrics_file.read_text(encoding="utf-8")))
except Exception:
pass
if __name__ == "__main__":
main()