Improve registration comparison viewer
155
app.py
@@ -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:
|
||||
|
||||
BIN
额外测试/slice080_app_sagittal_comparison_panel.png
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
额外测试/slice080_app_sagittal_moving_originalX160.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
额外测试/slice080_app_sagittal_warped_minus_moving_abs_heatmap.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 213 KiB |
BIN
额外测试/slice080_app_sagittal_warped_originalX160.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
89
额外测试/slice080_comparison_metadata.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
额外测试/slice080_model_axial_comparison_panel.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
额外测试/slice080_model_axial_moving.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
额外测试/slice080_model_axial_warped.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
额外测试/slice080_model_axial_warped_minus_moving_abs_heatmap.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
额外测试/slice080_model_axial_warped_minus_moving_signed_heatmap.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
额外测试/slice080_model_sagittal_comparison_panel.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
额外测试/slice080_model_sagittal_moving.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
额外测试/slice080_model_sagittal_warped.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
额外测试/slice080_model_sagittal_warped_minus_moving_abs_heatmap.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 105 KiB |
BIN
额外测试/slice080_raw_dicom_moving_reference.png
Normal file
|
After Width: | Height: | Size: 212 KiB |