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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -0,0 +1,89 @@
{
"moving_model_input_path": "outputs/inference/moving_model_input.nii.gz",
"warped_moving_path": "outputs/inference/warped_moving.nii.gz",
"moving_model_input_shape_xyz": [
320,
320,
352
],
"warped_moving_shape_xyz": [
320,
320,
352
],
"model_spacing_xyz_mm": [
1.0,
1.0,
1.0
],
"model_index_used_for_png": 80,
"difference_definition": "warped_moving - moving_model_input",
"intensity_unit": "preprocessed normalized intensity, not HU",
"outputs": [
{
"plane": "axial",
"moving_png": "额外测试/slice080_model_axial_moving.png",
"warped_png": "额外测试/slice080_model_axial_warped.png",
"signed_heatmap_png": "额外测试/slice080_model_axial_warped_minus_moving_signed_heatmap.png",
"absolute_heatmap_png": "额外测试/slice080_model_axial_warped_minus_moving_abs_heatmap.png",
"comparison_panel_png": "额外测试/slice080_model_axial_comparison_panel.png",
"diff_mean": 0.00033523677848279476,
"diff_std": 0.05242208391427994,
"diff_abs_mean": 0.012141597457230091,
"diff_abs_p95": 0.05994440242648125,
"diff_abs_p99": 0.26289790868759155,
"diff_min": -0.8903505206108093,
"diff_max": 0.8546980619430542,
"heatmap_limit_abs_p99": 0.26289790868759155,
"abs_heatmap_vmax_p99": 0.26289790868759155
},
{
"plane": "sagittal",
"moving_png": "额外测试/slice080_model_sagittal_moving.png",
"warped_png": "额外测试/slice080_model_sagittal_warped.png",
"signed_heatmap_png": "额外测试/slice080_model_sagittal_warped_minus_moving_signed_heatmap.png",
"absolute_heatmap_png": "额外测试/slice080_model_sagittal_warped_minus_moving_abs_heatmap.png",
"comparison_panel_png": "额外测试/slice080_model_sagittal_comparison_panel.png",
"diff_mean": 0.000903263979125768,
"diff_std": 0.03735477849841118,
"diff_abs_mean": 0.007962067611515522,
"diff_abs_p95": 0.04058312624692917,
"diff_abs_p99": 0.1862204670906067,
"diff_min": -0.8619486093521118,
"diff_max": 0.5144104957580566,
"heatmap_limit_abs_p99": 0.1862204670906067,
"abs_heatmap_vmax_p99": 0.1862204670906067
}
],
"raw_moving_dicom_reference": {
"path": "Data/患者1-仰头CT/00000080.dcm",
"png": "额外测试/slice080_raw_dicom_moving_reference.png",
"shape_rows_cols": [
512,
512
],
"instance_number": 80,
"pixel_spacing_row_col_mm": [
0.646484375,
0.646484375
],
"slice_thickness_mm": 1.0,
"note": "Raw DICOM is not directly subtracted because warped output is a 320x320x352 preprocessed NIfTI model grid."
},
"app_display_note": {
"streamlit_display_stride": 2,
"app_sagittal_slider_index": 80,
"corresponding_original_model_x_index": 160,
"moving_png": "额外测试/slice080_app_sagittal_moving_originalX160.png",
"warped_png": "额外测试/slice080_app_sagittal_warped_originalX160.png",
"signed_heatmap_png": "额外测试/slice080_app_sagittal_warped_minus_moving_signed_heatmap.png",
"absolute_heatmap_png": "额外测试/slice080_app_sagittal_warped_minus_moving_abs_heatmap.png",
"comparison_panel_png": "额外测试/slice080_app_sagittal_comparison_panel.png",
"difference_definition": "warped_moving - moving_model_input",
"diff_abs_mean": 0.021932723000645638,
"diff_abs_p95": 0.12442082911729813,
"diff_abs_p99": 0.30432429909706116,
"diff_min": -0.9248806834220886,
"diff_max": 0.8222805857658386
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB