CBT_project/imaging/segmentation.py
Xiao Furen 523ec7ee16 feat(core): implement vertebral body rewards and enhanced segmentation logic
Introduces a new scoring component for vertebral body (VBODY) rewards
to improve optimization accuracy. The update includes:

- Added `vbody_tensor` to `OptimizationContext` and scoring functions
  to reward screw placement within the vertebral body.
- Enhanced `segment_spinous_process` with diagnostic capabilities to
  detect spinous process absence (e.g., post-laminectomy).
- Improved `resample_img` to prevent physical boundary clipping and
  handle interpolation more robustly for CT and label data.
- Implemented a metadata cache using TinyDB in the preprocessing
  pipeline to skip low-resolution or insufficient scans efficiently.
- Added robust error handling for NFS-based file operations and
  directory creation.
- Added new visualization tools for bone figures and level plotting.

refactor(imaging): improve segmentation and resampling precision

- Refactored `seg_bone` to support original resolution binary masks and
  Signed Maurer Distance Maps (SMD) for more accurate boundary handling.
- Updated `resample_img` to use `ceil` for output size calculation to
  ensure full physical coverage.
- Optimized `process_single_image` to utilize metadata for skipping
  processing of invalid or low-quality scans.
2026-09-05 04:30:10 +08:00

161 lines
No EOL
8.3 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.

import os
import SimpleITK as sitk
from config.constant import LABEL_MAP
from imaging.resample import resample_img
import numpy as np
"""
# 沿用原本 LABEL_MAP
seg_bone(n, name, img, lbl)
# user 自定義
my_map = {1: "L1", 2: "L2", 3: "L3"}
seg_bone(n, name, img, lbl, label_map=my_map)
"""
def _largest_cc_bbox(mask_img):
"""26-連通的最大连通區域 + 其 bboxRelabelComponent 依大小排序,最大者=1
回傳 (largest_mask, bbox2);沒有任何組件時回傳 None。
bbox2 格式:[x_start, y_start, z_start, x_size, y_size, z_size]。"""
cc_image = sitk.ConnectedComponent(mask_img, True) # fullyConnected
relabeled_cc = sitk.RelabelComponent(cc_image, sortByObjectSize=True)
shape_stats = sitk.LabelShapeStatisticsImageFilter()
shape_stats.Execute(relabeled_cc)
if shape_stats.GetNumberOfLabels() < 1:
return None
return (relabeled_cc == 1), shape_stats.GetBoundingBox(1)
def _bbox_roi(img, bbox, margin=0):
"""裁 bbox對稱外擴 margin 個 voxelclamp 到影像邊界)。
bbox 格式:[x_start, y_start, z_start, x_size, y_size, z_size]。"""
n = img.GetSize() # (x, y, z)
index = [max(0, int(bbox[i]) - margin) for i in range(3)]
size = [min(n[i] - index[i], int(bbox[i + 3]) + 2 * margin) for i in range(3)]
return sitk.RegionOfInterest(img, size, index)
def seg_bone(n, name, resampled_sitk_img, resampled_sitk_lbl, output_base=None, label_map=LABEL_MAP,
original_label=None):
if output_base==None:
output_base=='Dataset'
if n not in label_map:
raise ValueError(f"Label {n} not found in label_map")
label_name = label_map[n]
# ============ 原解析度未插值chain ============
# 1. 提取標籤 n 的二值遮罩 (將標籤 n 設為 1其餘為 0),最大連通區域
smd_path = resampled_path = binary_sdf_path = binary_erode_path = None
binary_linear_path = binary_nn_path = None
if original_label is not None:
bin_orig = sitk.BinaryThreshold(original_label, n, n, 1, 0)
cc_orig = _largest_cc_bbox(bin_orig)
if cc_orig is None:
return None
largest_orig, bbox_orig = cc_orig
# _binary.nii.gz原解析度【未插值】遮罩最大连通區域、裁到物件 bbox
# 不重取樣、不插值 —— 原始 label 的忠實二值版本)
binary_path = os.path.join(output_base, f"{label_name}_binary.nii.gz")
sitk.WriteImage(_bbox_roi(largest_orig, bbox_orig), binary_path)
# _smd.nii.gzSignedMaurerDistanceMapITK 慣例:物件內負 / 外正;
# 距離以原始 indexpixel單位、不隨各向异性 spacing 縮放——
# _binary_sdf 的 0.5 閾值mid-gap正是依賴這個 index 單位慣例)。
# 裁 bbox_orig 外擴 4 voxel 的背景輪:
# 沒有背景輪時填充值直接貼着物件邊緣,重取樣會在裁切邊界產生
# 假的閾值穿越。
# 這個 SimpleITK build 的 3D SignedMaurerDistanceMap 只支援整數輸入,
# 先 Cast 到 uint8 再回傳 float32 輸出
smd_full = sitk.SignedMaurerDistanceMap(sitk.Cast(largest_orig, sitk.sitkUInt8))
smd_full = sitk.Cast(smd_full, sitk.sitkFloat32)
smd_margined = _bbox_roi(smd_full, bbox_orig, margin=4)
smd_path = os.path.join(output_base, f"{label_name}_smd.nii.gz")
sitk.WriteImage(smd_margined, smd_path)
# SMD 體積【线性插值】重取樣到 0.5mmreference = 0.5mm CT
# resampled_sitk_img 同 grid之後可直接用 0.5mm bbox 裁切)。
# SMD 在每個 input voxel 內分段線性,线性重取樣近似精確、無漣波,
# 各等值面(物件邊界等)不變。填充值 = 裁切角落(背景側,正值);
# 若為負(物件貼影像邊界的病態情況)用 0。
corner = float(sitk.GetArrayViewFromImage(smd_margined).flat[0])
rs = sitk.ResampleImageFilter()
rs.SetReferenceImage(resampled_sitk_img)
rs.SetInterpolator(sitk.sitkLinear)
rs.SetDefaultPixelValue(corner if corner > 0 else 0.0)
smd_res_full = rs.Execute(smd_margined)
# 0.5mm linear 二值化 maskfull extent與 resampled_sitk_img 同 grid
# 原數據 5mm 切片上採樣 10x 到 0.5mm,最近邻會在邊界產生 10 體素厚的
# 階梯鋸齒线性插值使邊界落在次體素位置rotation 前的邊界更平滑)。
bin_lin = resample_img(sitk.Cast(bin_orig, sitk.sitkFloat32))
arr = (sitk.GetArrayFromImage(bin_lin) > 0.5).astype(np.uint8)
if arr.shape != resampled_sitk_img.GetSize()[::-1]:
raise RuntimeError(
f"linear binary resample shape mismatch: {arr.shape} vs {resampled_sitk_img.GetSize()}")
binary_mask = sitk.GetImageFromArray(arr)
binary_mask.CopyInformation(resampled_sitk_img)
else:
# 無原始 label舊版路徑0.5mm label 閾值),不產生 SMD/SDF chain
binary_mask = sitk.BinaryThreshold(resampled_sitk_lbl, n, n, 1, 0)
binary_path = None
# 2. 0.5mm 最大連通區域26-連通)+ 邊界框;所有 0.5mm 輸出裁到同一 bbox
cc_res = _largest_cc_bbox(binary_mask)
if cc_res is None:
return None
largest_mask, bbox2 = cc_res
if binary_path is None:
binary_path = os.path.join(output_base, f"{label_name}_binary.nii.gz")
sitk.WriteImage(sitk.RegionOfInterest(largest_mask, bbox2[3:], bbox2[:3]), binary_path)
if smd_path is not None:
# _smd_resampled.nii.gz线性重取樣到 0.5mm 的 SMD浮點裁 bbox2
resampled_path = os.path.join(output_base, f"{label_name}_smd_resampled.nii.gz")
sitk.WriteImage(sitk.RegionOfInterest(smd_res_full, bbox2[3:], bbox2[:3]), resampled_path)
# _binary_sdf.nii.gz_smd_resampled 於 0.5 閾值 -> 0.5mm 平滑 mask
# SMD 內負/外正0.5 介於內殼 ≈0 與外殼 ≈+1 之間,即原解析度
# label 邊界的 mid-gap 位置physical volume 與 _binary/_binary_nn
# 一致sub-voxel 表面、無 NN 階梯)
bin_sdf_full = sitk.GetImageFromArray(
(sitk.GetArrayFromImage(smd_res_full) < 0.5).astype(np.uint8))
bin_sdf_full.CopyInformation(resampled_sitk_img)
binary_sdf_path = os.path.join(output_base, f"{label_name}_binary_sdf.nii.gz")
sitk.WriteImage(sitk.RegionOfInterest(bin_sdf_full, bbox2[3:], bbox2[:3]), binary_sdf_path)
# _binary_nn.nii.gz0.5mm label 最近邻(舊版),裁自己的 bbox僅供對比
nn_mask = sitk.BinaryThreshold(resampled_sitk_lbl, n, n, 1, 0)
nn_res = _largest_cc_bbox(nn_mask)
if nn_res is not None:
nn_largest, nn_bbox = nn_res
binary_nn_path = os.path.join(output_base, f"{label_name}_binary_nn.nii.gz")
sitk.WriteImage(sitk.RegionOfInterest(nn_largest, nn_bbox[3:], nn_bbox[:3]),
binary_nn_path)
# 3. roi0.5mm
# _roi2 不再存檔_cortical 改由 xfr_preprocess 的旋轉後處理產出
# rotated/{level}_cortical.nii.gz定義不變門檻 = 骨頭 mask 內 median HU
roi = sitk.RegionOfInterest(resampled_sitk_img, bbox2[3:], bbox2[:3])
roi_path = os.path.join(output_base, f"{label_name}_roi.nii.gz")
sitk.WriteImage(roi, roi_path)
return roi_path, binary_path, None, None, binary_nn_path, \
binary_linear_path, smd_path, resampled_path, binary_sdf_path, binary_erode_path
"""
Dataset/
└── standardized/
└── subject001/
├── L1_binary.nii.gz # 原解析度【未插值】遮罩(最大连通區域、裁物件 bbox
├── L1_smd.nii.gz # SignedMaurerDistanceMap內負/外正,原始 index 單位;
│ # bbox 外扩 4 voxel 背景輪,供重取樣插值用)
├── L1_smd_resampled.nii.gz # _smd 經线性插值重取樣到 0.5mm(浮點,裁 0.5mm bbox
├── L1_binary_sdf.nii.gz # _smd_resampled 於 0.5 閾值 -> 0.5mm 平滑 mask0/1裁同 bbox
├── L1_binary_nn.nii.gz # 最近邻版 0/1對比用各自 bbox
├── L1_roi.nii.gz
├── L2_binary.nii.gz
...
"""