CBT_project/imaging/segmentation.py
Xiao Furen d167c1f7c7 feat(imaging): implement coordinate transformation pipeline and directory restructuring
Introduce a robust coordinate transformation system to manage the relationship
between original CT space and rotated/standardized segmentation spaces.
This includes a new directory hierarchy to separate unrotated crops from
rotated outputs and utility functions for geometric mapping.

Key changes:
- Implement `imaging/transforms.py` to handle bounding box metadata,
  affine standardization, and coordinate mapping between spaces.
- Restructure dataset output: unrotated segmentation files (binary, SDF,
  ROI, etc.) are now stored in a `<vol>/crop/` subdirectory to distinguish
  them from `<vol>/rotated/` aligned versions.
- Add `level_file_path` utility to abstract file discovery across legacy
  (top-level) and new (crop-based) directory structures.
- Enhance `seg_bone` to capture and export bounding box metadata
  (`bbox2`, `nn_bbox`, `bbox_orig`) into `transform.json`.
- Implement `xfr_cbt_native.py` for mapping screw positions back to
  original CT space.
- Update preprocessing and visualization scripts to support the new
  directory layout and transformation metadata.
- Improve TinyDB metadata migration logic to prevent accidental corruption
  of existing database structures.
2026-09-09 13:39:47 +08:00

179 lines
No EOL
9.5 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
nn_bbox = bbox_orig = None # 裁切 boxx,y,z 序),供 transform.json 記錄
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)
# 第 11 項:該 level 的裁切 boxx,y,z 序 [x0,y0,z0,xs,ys,zs])與來源資訊,
# 供 transform.jsonimaging/transforms.py記錄原始 <-> 標準化座標鏈:
# bbox2 : 0.5mm 最大連通 bbox_smd_resampled / _binary_sdf / _roi 共用)
# nn_bbox : _binary_nn 自己的 bbox_smd_resampled 路徑才產生)
# bbox_orig : 原解析度最大連通 bbox_binary_smd = 其 +4 margin
return roi_path, binary_path, None, None, binary_nn_path, \
binary_linear_path, smd_path, resampled_path, binary_sdf_path, \
binary_erode_path, {
'label': int(n),
'level': label_name,
'has_smd': smd_path is not None,
'bbox2': [int(v) for v in bbox2],
'nn_bbox': [int(v) for v in nn_bbox] if nn_bbox is not None else None,
'bbox_orig': [int(v) for v in bbox_orig] if bbox_orig is not None else None,
}
"""
Dataset/
└── standardized/
└── subject001/
├── crop/ # 未旋转裁切片(旋转版见 <vol>/rotated/
│ ├── 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
│ ...
│ └── L1_planes.png # 未旋转 planes 图xfr_preprocess 后处理)
├── transform.json
└── rotated/ # 旋转对齐版xfr_preprocess 后处理)
"""