Improve registration comparison viewer

This commit is contained in:
admin
2026-06-03 17:12:33 +08:00
parent f7b6a051d4
commit 3af18dfe19
18 changed files with 212 additions and 32 deletions

155
app.py
View File

@@ -119,6 +119,28 @@ def configure_matplotlib_cjk_font() -> None:
configure_matplotlib_cjk_font()
OVERLAY_MANUAL_DEFAULTS = {
"overlay_manual_enabled": False,
"overlay_shift_x": 0.0,
"overlay_shift_y": 0.0,
"overlay_shift_z": 0.0,
"overlay_scale": 1.0,
"overlay_axial_rotation": 0.0,
"overlay_coronal_rotation": 0.0,
"overlay_sagittal_rotation": 0.0,
}
def ensure_overlay_manual_defaults() -> None:
for key, value in OVERLAY_MANUAL_DEFAULTS.items():
st.session_state.setdefault(key, value)
def reset_overlay_manual_controls() -> None:
for key, value in OVERLAY_MANUAL_DEFAULTS.items():
st.session_state[key] = value
def _require_nibabel():
try:
import nibabel as nib # type: ignore
@@ -293,10 +315,22 @@ def color_difference_overlay(fixed_slice: np.ndarray, warped_slice: np.ndarray,
return np.clip(rgb, 0.0, 1.0)
def signed_difference(fixed_slice: np.ndarray, warped_slice: np.ndarray) -> np.ndarray:
fixed_norm = normalize_slice(fixed_slice)
warped_norm = normalize_slice(warped_slice)
return warped_norm - fixed_norm
def signed_difference(base_slice: np.ndarray, target_slice: np.ndarray) -> np.ndarray:
"""计算模型强度差,输入体数据本身已在预处理阶段归一化到 [0, 1]。"""
return target_slice.astype(np.float32, copy=False) - base_slice.astype(np.float32, copy=False)
def difference_summary(diff: np.ndarray) -> Dict[str, float]:
finite = diff[np.isfinite(diff)]
if finite.size == 0:
return {"abs_mean": 0.0, "abs_p95": 0.0, "abs_p99": 0.0}
abs_diff = np.abs(finite)
return {
"abs_mean": float(np.mean(abs_diff)),
"abs_p95": float(np.percentile(abs_diff, 95.0)),
"abs_p99": float(np.percentile(abs_diff, 99.0)),
}
def ddf_magnitude(ddf_xyz: np.ndarray) -> np.ndarray:
@@ -351,7 +385,12 @@ def render_rgb(image: np.ndarray, caption: str, plane: str = "Axial") -> None:
plt.close(fig)
def render_signed_difference(image: np.ndarray, caption: str, plane: str = "Axial") -> None:
def render_signed_difference(
image: np.ndarray,
caption: str,
plane: str = "Axial",
colorbar_label: str = "归一化强度差",
) -> None:
fig, ax = plt.subplots(figsize=(4.8, 4.8), dpi=120)
diff = orient_for_display(image, plane)
finite = diff[np.isfinite(diff)]
@@ -360,7 +399,8 @@ def render_signed_difference(image: np.ndarray, caption: str, plane: str = "Axia
im = ax.imshow(diff, cmap="coolwarm", vmin=-vmax, vmax=vmax, interpolation="nearest")
ax.set_title(caption, fontsize=10)
ax.axis("off")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
cbar.set_label(colorbar_label)
st.pyplot(fig, width="stretch")
plt.close(fig)
@@ -389,7 +429,8 @@ def render_heatmap_overlay(
)
ax.set_title(caption, fontsize=10)
ax.axis("off")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
cbar.set_label("位移强度 mm")
st.pyplot(fig, width="stretch")
plt.close(fig)
@@ -435,13 +476,40 @@ def render_three_way_views(fixed_xyz: np.ndarray, moving_xyz: np.ndarray, warped
render_image(get_slice(volume, plane, index), f"{label} #{index}", plane=plane)
def render_overlay_views(fixed_xyz: np.ndarray, moving_xyz: np.ndarray, warped_xyz: np.ndarray) -> None:
def render_overlay_views(
fixed_xyz: np.ndarray,
moving_xyz: np.ndarray,
warped_xyz: np.ndarray,
display_stride: int = 1,
) -> None:
fixed_xyz, moving_xyz, warped_xyz = crop_to_common_shape(fixed_xyz, moving_xyz, warped_xyz)
target_label = st.radio("叠加对象", ["配准后图像", "移动图像"], horizontal=True)
target_xyz = warped_xyz if target_label == "配准后图像" else moving_xyz
comparison = st.radio(
"对比组合",
["配准后图像 vs 移动图像", "配准后图像 vs 固定图像", "移动图像 vs 固定图像"],
horizontal=True,
)
if comparison == "配准后图像 vs 移动图像":
base_label = "移动图像"
target_label = "配准后图像"
base_xyz = moving_xyz
target_xyz = warped_xyz
diff_label = "配准后 - 移动"
elif comparison == "配准后图像 vs 固定图像":
base_label = "固定图像"
target_label = "配准后图像"
base_xyz = fixed_xyz
target_xyz = warped_xyz
diff_label = "配准后 - 固定"
else:
base_label = "固定图像"
target_label = "移动图像"
base_xyz = fixed_xyz
target_xyz = moving_xyz
diff_label = "移动 - 固定"
mode = st.radio("对比模式", ["灰度融合", "红蓝差异", "差异热图", "棋盘格"], horizontal=True)
if mode in {"灰度融合", "红蓝差异"}:
alpha = st.slider("被叠加图像权重", 0.0, 1.0, 0.5, 0.05)
alpha = st.slider(f"{target_label}权重", 0.0, 1.0, 0.5, 0.05)
else:
alpha = 0.5
if mode == "棋盘格":
@@ -450,28 +518,30 @@ def render_overlay_views(fixed_xyz: np.ndarray, moving_xyz: np.ndarray, warped_x
tile = 24
if mode == "红蓝差异":
st.caption("红/金色偏向被叠加图像,蓝色偏向固定图像;白灰区域表示两者灰度接近。")
st.caption(f"红/金色偏向{target_label},蓝色偏向{base_label};白灰区域表示两者灰度接近。")
elif mode == "差异热图":
st.caption("差异热图显示被叠加图像减固定图像的灰度差色条以当前切片的 99% 绝对差自动缩放。")
st.caption(f"差异热图显示 {diff_label} 的模型归一化强度差色条以当前切片的 99% 绝对差自动缩放。")
with st.expander("人工微调重叠", expanded=True):
enabled = st.toggle("启用人工微调", value=False)
ensure_overlay_manual_defaults()
st.button("恢复原始状态", on_click=reset_overlay_manual_controls)
enabled = st.toggle("启用人工微调", key="overlay_manual_enabled")
c1, c2, c3, c4 = st.columns(4)
with c1:
shift_x = st.slider("X 平移", -80.0, 80.0, 0.0, 0.5)
shift_x = st.slider("X 平移", -80.0, 80.0, step=0.5, key="overlay_shift_x")
with c2:
shift_y = st.slider("Y 平移", -80.0, 80.0, 0.0, 0.5)
shift_y = st.slider("Y 平移", -80.0, 80.0, step=0.5, key="overlay_shift_y")
with c3:
shift_z = st.slider("Z 平移", -80.0, 80.0, 0.0, 0.5)
shift_z = st.slider("Z 平移", -80.0, 80.0, step=0.5, key="overlay_shift_z")
with c4:
scale = st.slider("缩放", 0.85, 1.15, 1.0, 0.005)
scale = st.slider("缩放", 0.85, 1.15, step=0.005, key="overlay_scale")
r1, r2, r3 = st.columns(3)
with r1:
axial_rotation = st.slider("轴状面旋转", -15.0, 15.0, 0.0, 0.5)
axial_rotation = st.slider("轴状面旋转", -15.0, 15.0, step=0.5, key="overlay_axial_rotation")
with r2:
coronal_rotation = st.slider("冠状面旋转", -15.0, 15.0, 0.0, 0.5)
coronal_rotation = st.slider("冠状面旋转", -15.0, 15.0, step=0.5, key="overlay_coronal_rotation")
with r3:
sagittal_rotation = st.slider("矢状面旋转", -15.0, 15.0, 0.0, 0.5)
sagittal_rotation = st.slider("矢状面旋转", -15.0, 15.0, step=0.5, key="overlay_sagittal_rotation")
if enabled:
st.caption(
f"当前人工微调X={shift_x:+.1f}, Y={shift_y:+.1f}, Z={shift_z:+.1f}, "
@@ -492,7 +562,7 @@ def render_overlay_views(fixed_xyz: np.ndarray, moving_xyz: np.ndarray, warped_x
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)
base_slice = get_slice(base_xyz, plane, index)
target_slice = get_slice(target_xyz, plane, index)
target_slice = apply_manual_slice_transform(
target_slice,
@@ -501,14 +571,28 @@ def render_overlay_views(fixed_xyz: np.ndarray, moving_xyz: np.ndarray, warped_x
scale=scale_value,
rotation_deg=rotation_by_plane[plane],
)
slice_label = f"{cn_name} #{index}"
if display_stride > 1:
slice_label = f"{slice_label} / 原始 #{index * display_stride}"
if mode == "棋盘格":
render_image(checkerboard(fixed_slice, target_slice, tile=tile), f"{cn_name} 棋盘格 #{index}", plane=plane)
render_image(checkerboard(base_slice, target_slice, tile=tile), f"{slice_label} 棋盘格", plane=plane)
elif mode == "红蓝差异":
render_rgb(color_difference_overlay(fixed_slice, target_slice, alpha=alpha), f"{cn_name} 红蓝差异 #{index}", plane=plane)
render_rgb(color_difference_overlay(base_slice, target_slice, alpha=alpha), f"{slice_label} 红蓝差异", plane=plane)
elif mode == "差异热图":
render_signed_difference(signed_difference(fixed_slice, target_slice), f"{cn_name} 差异热图 #{index}", plane=plane)
diff = signed_difference(base_slice, target_slice)
render_signed_difference(
diff,
f"{slice_label} 差异热图",
plane=plane,
colorbar_label=f"{diff_label},归一化强度",
)
stats = difference_summary(diff)
st.caption(
f"|差|均值={stats['abs_mean']:.4f}"
f"P95={stats['abs_p95']:.4f}P99={stats['abs_p99']:.4f}"
)
else:
render_image(gray_fusion(fixed_slice, target_slice, alpha=alpha), f"{cn_name} 灰度融合 #{index}", plane=plane)
render_image(gray_fusion(base_slice, target_slice, alpha=alpha), f"{slice_label} 灰度融合", plane=plane)
def render_ddf_views(fixed_xyz: np.ndarray, ddf_xyz: np.ndarray) -> None:
@@ -551,7 +635,7 @@ def render_ddf_views(fixed_xyz: np.ndarray, ddf_xyz: np.ndarray) -> None:
vmin=vmin,
vmax=vmax,
show_base=show_base,
caption=f"{cn_name} 形变强度 #{index}",
caption=f"{cn_name} 位移强度 #{index}",
)
@@ -896,19 +980,26 @@ def main() -> None:
warped_xyz = None
ddf_xyz = None
warped_stride = 1
ddf_stride = 1
if Path(warped_path).exists():
try:
warped_xyz, _, _ = load_nifti_cached(warped_path, max_voxels=display_max_voxels)
warped_xyz, _, warped_stride = 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)
ddf_xyz, _, ddf_stride = 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}")
display_stride = max(moving_stride, fixed_stride, warped_stride)
if display_stride > 1 or ddf_stride > 1:
st.info(
f"网页显示已自动下采样Moving stride={moving_stride}, Fixed stride={fixed_stride}, "
f"Warped stride={warped_stride}, DDF stride={ddf_stride}"
f"切片标题会同时显示网页索引和原始模型网格索引,例如 stride=2 时 #80 对应原始 #160。"
)
tab_views, tab_overlay, tab_ddf, tab_metrics = st.tabs(["正交三视图", "重叠对比", "形变场", "量化图"])
with tab_views:
@@ -918,7 +1009,7 @@ def main() -> None:
if warped_xyz is None:
st.warning("尚未生成配准后图像。")
else:
render_overlay_views(fixed_xyz, moving_xyz, warped_xyz)
render_overlay_views(fixed_xyz, moving_xyz, warped_xyz, display_stride=display_stride)
with tab_ddf:
if ddf_xyz is None: