diff --git a/app.py b/app.py index 40fac35..9aa1303 100644 --- a/app.py +++ b/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: diff --git a/额外测试/slice080_app_sagittal_comparison_panel.png b/额外测试/slice080_app_sagittal_comparison_panel.png new file mode 100644 index 0000000..b69336f Binary files /dev/null and b/额外测试/slice080_app_sagittal_comparison_panel.png differ diff --git a/额外测试/slice080_app_sagittal_moving_originalX160.png b/额外测试/slice080_app_sagittal_moving_originalX160.png new file mode 100644 index 0000000..ff05776 Binary files /dev/null and b/额外测试/slice080_app_sagittal_moving_originalX160.png differ diff --git a/额外测试/slice080_app_sagittal_warped_minus_moving_abs_heatmap.png b/额外测试/slice080_app_sagittal_warped_minus_moving_abs_heatmap.png new file mode 100644 index 0000000..d690a61 Binary files /dev/null and b/额外测试/slice080_app_sagittal_warped_minus_moving_abs_heatmap.png differ diff --git a/额外测试/slice080_app_sagittal_warped_minus_moving_signed_heatmap.png b/额外测试/slice080_app_sagittal_warped_minus_moving_signed_heatmap.png new file mode 100644 index 0000000..7f30fa9 Binary files /dev/null and b/额外测试/slice080_app_sagittal_warped_minus_moving_signed_heatmap.png differ diff --git a/额外测试/slice080_app_sagittal_warped_originalX160.png b/额外测试/slice080_app_sagittal_warped_originalX160.png new file mode 100644 index 0000000..2f8386d Binary files /dev/null and b/额外测试/slice080_app_sagittal_warped_originalX160.png differ diff --git a/额外测试/slice080_comparison_metadata.json b/额外测试/slice080_comparison_metadata.json new file mode 100644 index 0000000..40915d4 --- /dev/null +++ b/额外测试/slice080_comparison_metadata.json @@ -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 + } +} \ No newline at end of file diff --git a/额外测试/slice080_model_axial_comparison_panel.png b/额外测试/slice080_model_axial_comparison_panel.png new file mode 100644 index 0000000..a443579 Binary files /dev/null and b/额外测试/slice080_model_axial_comparison_panel.png differ diff --git a/额外测试/slice080_model_axial_moving.png b/额外测试/slice080_model_axial_moving.png new file mode 100644 index 0000000..f750fd7 Binary files /dev/null and b/额外测试/slice080_model_axial_moving.png differ diff --git a/额外测试/slice080_model_axial_warped.png b/额外测试/slice080_model_axial_warped.png new file mode 100644 index 0000000..f4c7d4b Binary files /dev/null and b/额外测试/slice080_model_axial_warped.png differ diff --git a/额外测试/slice080_model_axial_warped_minus_moving_abs_heatmap.png b/额外测试/slice080_model_axial_warped_minus_moving_abs_heatmap.png new file mode 100644 index 0000000..a77112a Binary files /dev/null and b/额外测试/slice080_model_axial_warped_minus_moving_abs_heatmap.png differ diff --git a/额外测试/slice080_model_axial_warped_minus_moving_signed_heatmap.png b/额外测试/slice080_model_axial_warped_minus_moving_signed_heatmap.png new file mode 100644 index 0000000..0d58c2d Binary files /dev/null and b/额外测试/slice080_model_axial_warped_minus_moving_signed_heatmap.png differ diff --git a/额外测试/slice080_model_sagittal_comparison_panel.png b/额外测试/slice080_model_sagittal_comparison_panel.png new file mode 100644 index 0000000..2219c25 Binary files /dev/null and b/额外测试/slice080_model_sagittal_comparison_panel.png differ diff --git a/额外测试/slice080_model_sagittal_moving.png b/额外测试/slice080_model_sagittal_moving.png new file mode 100644 index 0000000..688f0ab Binary files /dev/null and b/额外测试/slice080_model_sagittal_moving.png differ diff --git a/额外测试/slice080_model_sagittal_warped.png b/额外测试/slice080_model_sagittal_warped.png new file mode 100644 index 0000000..1bab9d0 Binary files /dev/null and b/额外测试/slice080_model_sagittal_warped.png differ diff --git a/额外测试/slice080_model_sagittal_warped_minus_moving_abs_heatmap.png b/额外测试/slice080_model_sagittal_warped_minus_moving_abs_heatmap.png new file mode 100644 index 0000000..f3c20f3 Binary files /dev/null and b/额外测试/slice080_model_sagittal_warped_minus_moving_abs_heatmap.png differ diff --git a/额外测试/slice080_model_sagittal_warped_minus_moving_signed_heatmap.png b/额外测试/slice080_model_sagittal_warped_minus_moving_signed_heatmap.png new file mode 100644 index 0000000..9156421 Binary files /dev/null and b/额外测试/slice080_model_sagittal_warped_minus_moving_signed_heatmap.png differ diff --git a/额外测试/slice080_raw_dicom_moving_reference.png b/额外测试/slice080_raw_dicom_moving_reference.png new file mode 100644 index 0000000..faba133 Binary files /dev/null and b/额外测试/slice080_raw_dicom_moving_reference.png differ