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.
This commit is contained in:
xfr 2026-09-05 04:30:10 +08:00
parent 4f5be3d3e9
commit 523ec7ee16
14 changed files with 2655 additions and 486 deletions

2
.gitignore vendored
View file

@ -217,4 +217,6 @@ __marimo__/
.kilo/ .kilo/
logs/ logs/
results/
progress.json progress.json
xfr_image_metadata*

View file

@ -34,6 +34,8 @@ class OptimizationContext:
Optional: Optional:
grid: precomputed coordinate grid (z_t, y_t, x_t) grid: precomputed coordinate grid (z_t, y_t, x_t)
use_tip_penalty: add the tip-cylinder penalty to the loss use_tip_penalty: add the tip-cylinder penalty to the loss
vbody_tensor: VBODY (vertebral body) mask 0/1 adds the VBODY
voxel rewards in cl_score_torch_xfr
The *_array / spine_roi_tensor / diameter / length fields are kept The *_array / spine_roi_tensor / diameter / length fields are kept
for compatibility and debugging; the loss itself does not use them. for compatibility and debugging; the loss itself does not use them.
""" """
@ -44,6 +46,7 @@ class OptimizationContext:
device: torch.device device: torch.device
grid: Optional[tuple] = None grid: Optional[tuple] = None
use_tip_penalty: bool = False use_tip_penalty: bool = False
vbody_tensor: Optional[torch.Tensor] = None
spine_roi_tensor: Optional[torch.Tensor] = None spine_roi_tensor: Optional[torch.Tensor] = None
image1_array: Optional[np.ndarray] = None image1_array: Optional[np.ndarray] = None
image2_array: Optional[np.ndarray] = None image2_array: Optional[np.ndarray] = None
@ -121,7 +124,8 @@ def cylinder_circle_line_intersection_loss_deductions_torch(
loss_value = cl_score_torch_xfr( loss_value = cl_score_torch_xfr(
ctx.cortical_tensor, ctx.spine_tensor, ctx.cortical_tensor, ctx.spine_tensor,
cyl_fwd, cyl_opp, intersections, cyl_fwd, cyl_opp, intersections,
cylinder_tip_torch=cyl_tip cylinder_tip_torch=cyl_tip,
vbody_tensor=ctx.vbody_tensor
) )
return loss_value return loss_value

View file

@ -1,10 +1,12 @@
import json
import os
import time import time
from datetime import datetime from datetime import datetime
import SimpleITK as sitk import SimpleITK as sitk
import torch import torch
from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour, from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour,
best_symmetry_plane, best_upper_endplate_plane, best_symmetry_plane, best_upper_endplate_plane,
segment_spinous_process) segment_spinous_process, segment_vertebral_body)
from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
from core.objective import OptimizationContext, make_objective_function, make_objective_function_xfr from core.objective import OptimizationContext, make_objective_function, make_objective_function_xfr
from pyswarm import pso from pyswarm import pso
@ -16,12 +18,30 @@ from visualization.res_plot_3d import res_plt_2_torch
LATERAL_REFINE_MIN_IN_BONE = 0.97 LATERAL_REFINE_MIN_IN_BONE = 0.97
# 入口柱向前(+y椎體方向幾個 voxel 內進入 VBODY 即視為「入口在椎體上」:
# 涵蓋椎體後側皮質邊緣mask 外 1~2 voxel 的分割界線帶);
# 真正的後側要素與椎體間隔(椎間孔)大於此值,不受影響。
VBODY_ENTRY_EDGE = 4
def _makedirs_retry(path, retries=5, delay=0.5):
"""NFS 上建目錄重試(同 res_plot_3d._retry_robust 處理的瞬時錯誤)"""
for i in range(retries):
try:
os.makedirs(path, exist_ok=True)
return
except OSError:
if i == retries - 1:
raise
time.sleep(delay)
def refine_lateral_longer( def refine_lateral_longer(
z, x, azimuth, altitude, diameter_raw, length_raw, z, x, azimuth, altitude, diameter_raw, length_raw,
side, az_bounds, x_bounds, y_indices, side, az_bounds, x_bounds, y_indices,
image_shape, spacing, device, grid, image_shape, spacing, device, grid,
cortical_tensor, spine_tensor, cortical_tensor, spine_tensor,
vbody_tensor=None,
): ):
""" """
Deterministic local refinement after PSO: try aiming more laterally and Deterministic local refinement after PSO: try aiming more laterally and
@ -44,7 +64,8 @@ def refine_lateral_longer(
return None return None
inter, _ = center_line_intersections_torch(z_c, y_c, x_c, az_c, alt_c, inter, _ = center_line_intersections_torch(z_c, y_c, x_c, az_c, alt_c,
L_c, spine_tensor, spacing, device) L_c, spine_tensor, spacing, device)
loss = cl_score_torch_xfr(cortical_tensor, spine_tensor, cyl, cyl_o, inter) loss = cl_score_torch_xfr(cortical_tensor, spine_tensor, cyl, cyl_o, inter,
vbody_tensor=vbody_tensor)
in_bone = ((spine_tensor == 1) & (cyl == 1)).sum().item() / cyl.sum().item() in_bone = ((spine_tensor == 1) & (cyl == 1)).sum().item() / cyl.sum().item()
return {'pos': cand, 'loss': loss, 'in_bone': in_bone} return {'pos': cand, 'loss': loss, 'in_bone': in_bone}
@ -86,7 +107,20 @@ def _validate_bounds(lb, ub, name):
raise ValueError(f'PSO bounds invalid for {name}: dim {i} lower {lo} >= upper {hi}') raise ValueError(f'PSO bounds invalid for {name}: dim {i} lower {lo} >= upper {hi}')
def get_first_nonzero_y(arr): def get_first_nonzero_y(arr, endplate_plane=None, vbody_mask=None):
"""每個 (z, x) 柱的第一個 bone voxel 的 yvoxel index
endplate_plane {'plane': (a, b, c, d), ...}法線朝上c > 0
入口點 (x, y, z) 落在終板面之上的柱也設 OUTSIDE_VALUE
避免從終板上方的柱選入射點
vbody_mask(z, y, x) 0/1VBODY 椎體 mask提供時
入口點落在椎體上的柱設 OUTSIDE_VALUE避免螺絲入口點放在椎體上
入口 voxel 本身在 VBODY 或往前+y椎體方向 VBODY_ENTRY_EDGE
voxel 內進入 VBODY椎體後側皮質邊緣mask 0.5~2mm 的交界帶
都算真正的後側要素椎弓根/椎板/關節突厚度遠大於該邊緣
且與椎體之間有椎間孔間隔不會被誤排
"""
OUTSIDE_VALUE = -100 OUTSIDE_VALUE = -100
# 1. Create a boolean mask where elements are non-zero # 1. Create a boolean mask where elements are non-zero
@ -105,6 +139,34 @@ def get_first_nonzero_y(arr):
y_indices = np.where(y_indices < arr.shape[1] * .1, OUTSIDE_VALUE, y_indices) y_indices = np.where(y_indices < arr.shape[1] * .1, OUTSIDE_VALUE, y_indices)
y_indices = np.where(y_indices > arr.shape[1] * .4, OUTSIDE_VALUE, y_indices) y_indices = np.where(y_indices > arr.shape[1] * .4, OUTSIDE_VALUE, y_indices)
# 5. 入口點在終板面上方a*x + b*y + c*z - d > 0的柱設 OUTSIDE_VALUE
if endplate_plane is not None and y_indices.max() >= 0:
a, b, c, d = endplate_plane['plane']
z_grid, x_grid = np.meshgrid(np.arange(arr.shape[0]), np.arange(arr.shape[2]),
indexing='ij')
above = (y_indices >= 0) & (a * x_grid + b * y_indices + c * z_grid > d)
y_indices = np.where(above, OUTSIDE_VALUE, y_indices)
# 6. 入口點落在椎體上的柱設 OUTSIDE_VALUE入口不放椎體
# 入口 voxel 本身在 VBODY 內,或往前 VBODY_ENTRY_EDGE 個 voxel 內進入
# VBODY椎體後側皮質邊緣mask 外 0.5~2mm 的分割界線帶)都排除。
if vbody_mask is not None:
vb = np.asarray(vbody_mask) > 0
valid = y_indices >= 0
y_i = np.where(valid, y_indices, 0).astype(np.int64)
z_grid, x_grid = np.meshgrid(np.arange(arr.shape[0]), np.arange(arr.shape[2]),
indexing='ij')
on_vbody = np.zeros(y_indices.shape, dtype=bool)
for k in range(VBODY_ENTRY_EDGE + 1):
y_k = np.minimum(y_i + k, arr.shape[1] - 1)
on_vbody |= valid & vb[z_grid, y_k, x_grid]
n_vb = int(on_vbody.sum())
if n_vb:
y_indices = np.where(on_vbody, OUTSIDE_VALUE, y_indices)
print(f"[Y-INDEX] VBODY entry excluded: {n_vb} columns whose entry "
f"is on/within {VBODY_ENTRY_EDGE} vox of VBODY removed from "
f"entry surface")
return y_indices.astype(np.float32) return y_indices.astype(np.float32)
def constraint_y(x, y_indices): def constraint_y(x, y_indices):
@ -125,6 +187,11 @@ def run_pso_torch_xfr(
grid=None, grid=None,
debug=False, debug=False,
omega = 0.9, omega = 0.9,
side: str = 'both',
level: str = None,
patient_id: str = None,
side_dir: str = None,
run_id: str = None,
): ):
""" """
Main function to run PSO. Main function to run PSO.
@ -169,19 +236,19 @@ def run_pso_torch_xfr(
length=length if not optimize_size else None, length=length if not optimize_size else None,
) )
if not CBT:
azi = azimuth_rotation(image2_path) azi = azimuth_rotation(image2_path)
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False) res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
alt = res['superior']['tilt_angle_deg'] alt = res['superior']['tilt_angle_deg']
# ===== az/alt 搜尋範圍錨點:以椎體自身座標系取代 2D 近似 ===== # ===== 平面:影像已由 xfr_preprocess 對齊旋轉到椎體基準系 =====
# 鏡稱對稱面(最佳 mirror plane法線 (s_nx, s_ny, s_nz)s_nx>0 固定符號 # (鏡稱面法線 -> +x、上終板 normal y=0 / z>0az/alt 範圍改用固定
# theta_v = atan2(s_ny, s_nx) 是椎體真實左右軸相對 +x 的旋轉角, # 約束(見下方 CBT bounds不再逐椎以 theta_v / tau 重新錨定。
# 取代舊 2D 輪廓前點角 azi舊式 az 中心 90-azi == 新式 90+theta_v # 此處平面仅供:
# 上終板面normal 朝上, e_nz>0 # - 棘突移除segment_spinous_processsym
# tau_y = atan2(e_ny, e_nz):終板 AP 面傾斜;負 = 面向 +y終板側升高 # - 入口面終板上方剪除get_first_nonzero_yendplate
# 軌跡需爬得更陡 → altitude 中心減小(舊式 65-alt 的 3D 版)。 # - VBODY 椎體分割segment_vertebral_bodysym + endplate
# tau_x = atan2(e_nx, e_nz):終板 LR 面傾斜;正 = 面向小 xL 側)升高, # - 對齊健全性檢查:基準系下 theta_v ≈ 0、tau_y ≈ 0偏大 = 預處理失效)
# L 側需更陡(-tau_x、R 側較平(+tau_x
sym_plane = best_symmetry_plane(image2_array) sym_plane = best_symmetry_plane(image2_array)
# 入口面 y_indices 取「每個 (z,x) 柱第一個 bone voxel最後側 # 入口面 y_indices 取「每個 (z,x) 柱第一個 bone voxel最後側
@ -189,19 +256,51 @@ def run_pso_torch_xfr(
# 見 segment_spinous_process從 image2_array 移除再取 surface # 見 segment_spinous_process從 image2_array 移除再取 surface
# 讓 y_indices 永不落在棘突上loss 用 spine_tensor不受影響 # 讓 y_indices 永不落在棘突上loss 用 spine_tensor不受影響
sp_mask, sp_th, sp_info = segment_spinous_process(image2_array, sym_plane) sp_mask, sp_th, sp_info = segment_spinous_process(image2_array, sym_plane)
if sp_mask is not None and sp_mask.any():
# 上終板面與椎體都在「完整 maskSP 移除前)」上計算:
# - 終板面由前側頂面擬合SP 移除不改變平面;
# - 椎體與 res_plt_2_torch 的 gold 顯示完全同 input / 同參數,
# 確保顯示出來的椎體就是 loss 裡 VBODY 獎勵的區域。
# 棘突缺如laminectomymode='no_spinous')時不該把殘留後側要素
# 當「棘突」移除(會鏟進椎體後側),入口面維持完整 mask。
endplate_plane = best_upper_endplate_plane(image2_array)
# VBODY 評分獎勵:兩平面(鏡稱面 + 上終板)切出的椎體 mask
vb_mask_np, vb_th, vb_info = segment_vertebral_body(image2_array, sym_plane,
endplate_plane, sp_th, sp_info['mode'])
vbody_tensor = None
if vb_mask_np is not None and vb_mask_np.any():
vbody_tensor = torch.from_numpy(vb_mask_np.astype(np.uint8)).to(device=device)
print(f"[VBODY-SCORE] n={vb_info['n_vb']} "
f"({100.0 * vb_info['n_vb'] / max(int(image2_array.sum()), 1):.1f}% of bone) "
f"AP<{vb_info['ap_thresh']:.1f} mode={vb_info['mode']} -> added to loss")
if vb_info['mode'] == 'quantile':
print(f"[VBODY-SCORE] WARNING: 未找到體/弓後側谷底,閾值退回 55 百分位 "
f"(可能切進椎體內),建議人工核對該 level 的椎體邊界")
else:
print(f"[VBODY-SCORE] skipped: {vb_info['mode']}")
ctx.vbody_tensor = vbody_tensor
if sp_info['mode'] == 'no_spinous':
top_off = f"{sp_info['top_off']:.1f}" if sp_info.get('top_off') is not None else 'n/a'
print(f"[NO-SP] 中線後側缺如(先前 laminectomy / 棘突切除): "
f"deficit={sp_info['deficit']:.1f} vox ({sp_info['deficit'] * 0.5:.1f} mm), "
f"rear3={sp_info['rear3']} vox, top_off={top_off} vox "
f"-> 不移除 SP完整 mask 取入口面),椎體用放寬後側谷底切分")
elif sp_mask is not None and sp_mask.any():
n_sp = int(sp_mask.sum()) n_sp = int(sp_mask.sum())
image2_array[sp_mask] = 0 image2_array[sp_mask] = 0
print(f"[Y-INDEX] spinous process removed from entry surface: {n_sp} vox " print(f"[Y-INDEX] spinous process removed from entry surface: {n_sp} vox "
f"(band=+/-{sp_info['band_w']:.1f} voxel, AP>={sp_info['ap_thresh']:.1f}, " f"(band=+/-{sp_info['band_w']:.1f} voxel, AP>={sp_info['ap_thresh']:.1f}, "
f"mode={sp_info['mode']})") f"mode={sp_info['mode']})")
y_indices = get_first_nonzero_y(image2_array) y_indices = get_first_nonzero_y(image2_array, endplate_plane, vb_mask_np)
objective_fn = make_objective_function_xfr(ctx, y_indices) objective_fn = make_objective_function_xfr(ctx, y_indices)
# 對齊健全性檢查(基準系下 theta_v ≈ 0、tau_y ≈ 0不參與 bounds 計算)
s_nx, s_ny, s_nz = sym_plane['normal'] s_nx, s_ny, s_nz = sym_plane['normal']
theta_v = float(np.degrees(np.arctan2(s_ny, s_nx))) theta_v = float(np.degrees(np.arctan2(s_ny, s_nx)))
endplate_plane = best_upper_endplate_plane(image2_array)
if endplate_plane is not None: if endplate_plane is not None:
e_nx, e_ny, e_nz = endplate_plane['normal'] e_nx, e_ny, e_nz = endplate_plane['normal']
tau_y = float(np.degrees(np.arctan2(e_ny, e_nz))) tau_y = float(np.degrees(np.arctan2(e_ny, e_nz)))
@ -217,7 +316,7 @@ def run_pso_torch_xfr(
f" (tau_y={tau_y:+.2f} deg, tau_x={tau_x:+.2f} deg, " f" (tau_y={tau_y:+.2f} deg, tau_x={tau_x:+.2f} deg, "
f"inlier={endplate_plane['inlier_ratio']:.2f})") f"inlier={endplate_plane['inlier_ratio']:.2f})")
else: else:
print("[PLANE] endplate: 資料不足,altitude 錨點退回 tau_y=tau_x=0") print("[PLANE] endplate: 資料不足,終板入口剪除停用")
# flat_min_index = np.argmin(y_indices) # flat_min_index = np.argmin(y_indices)
# z_border, x_border = np.unravel_index(flat_min_index, y_indices.shape) # z_border, x_border = np.unravel_index(flat_min_index, y_indices.shape)
@ -282,18 +381,21 @@ def run_pso_torch_xfr(
if x_bounds_right[0] >= x_bounds_right[1]: if x_bounds_right[0] >= x_bounds_right[1]:
x_bounds_right = (x_bounds_right[1] - min_band, x_bounds_right[1]) x_bounds_right = (x_bounds_right[1] - min_band, x_bounds_right[1])
# 固定約束(影像已由 xfr_preprocess 對齊旋轉到椎體基準系:鏡稱面法線 +x、
# 終板 normal y=0 / z>0基準系下 theta_v≈0、tau_y≈0舊式逐椎錨定
# (98+theta_v, 105+theta_v) / (60+tau_y∓tau_x*sin_mid, 70+tau_y∓tau_x*sin_mid)
# 不再需要):
# azimuth (Lateral)az=90° 為 AP 直向,每側向外發散 8~20°
# L = 90 + (8~20) = 98~110、R = 90 - (8~20) = 70~82
# altitude (Cephalad):相對終板面 25~30°+z 極角 90 - 25~30= 60~65
# 舊 2D 版本(以輪廓角 azi / 矢狀面傾斜 alt 平移固定範圍),保留供對照: # 舊 2D 版本(以輪廓角 azi / 矢狀面傾斜 alt 平移固定範圍),保留供對照:
# azimuth_bounds_l = ((98-azi), (120-azi)) # azimuth_bounds_l = ((98-azi), (120-azi))
# azimuth_bounds_r = ((60-azi), (82-azi)) # azimuth_bounds_r = ((60-azi), (82-azi))
# altitude_bounds = ((60-alt), (70-alt)) # altitude_bounds = ((60-alt), (70-alt))
# 新:範圍以「椎體自身座標系」為中心 —— 椎體系中 az=90° 是 AP 直向、 azimuth_bounds_l = (98, 110)
# L 帶 = AP 後退 8~30°偏 -x、R 帶 = AP 前進 8~30°偏 +x azimuth_bounds_r = (70, 82)
# altitude 60~70。再換算回影像系az 整體加 theta_v鏡稱面 altitude_bounds_l = (60, 65)
# altitude 加 tau_y終板 AP 傾斜)並逐側加 -/+tau_x終板 LR 傾斜)。 altitude_bounds_r = (60, 65)
azimuth_bounds_l = ((98+theta_v), (120+theta_v))
azimuth_bounds_r = ((60+theta_v), (82+theta_v))
altitude_bounds_l = ((60+tau_y-tau_x), (70+tau_y-tau_x))
altitude_bounds_r = ((60+tau_y+tau_x), (70+tau_y+tau_x))
else: else:
z_bounds = (0, image_shape[0] - 1) z_bounds = (0, image_shape[0] - 1)
@ -340,7 +442,8 @@ def run_pso_torch_xfr(
diameter_bounds = (min(ALLOWED_DIAMETERS), max(ALLOWED_DIAMETERS)*1.01) diameter_bounds = (min(ALLOWED_DIAMETERS), max(ALLOWED_DIAMETERS)*1.01)
length_bounds = (min(ALLOWED_LENGTHS), max(ALLOWED_LENGTHS)*1.01) length_bounds = (min(ALLOWED_LENGTHS), max(ALLOWED_LENGTHS)*1.01)
# bounds 現在有 7 個參數altitude 分 L/R 兩側,由終板面 tau_x 決定) # bounds 現在有 6 個參數 [z, x, az, alt, d, L]y 由 y_indices 取);
# az/alt 兩側用同一組固定約束x 帶依 L/R 分開
lb_l = [z_bounds[0], x_bounds_left[0], azimuth_bounds_l[0], lb_l = [z_bounds[0], x_bounds_left[0], azimuth_bounds_l[0],
altitude_bounds_l[0], diameter_bounds[0], length_bounds[0]] altitude_bounds_l[0], diameter_bounds[0], length_bounds[0]]
ub_l = [z_bounds[1], x_bounds_left[1], azimuth_bounds_l[1], ub_l = [z_bounds[1], x_bounds_left[1], azimuth_bounds_l[1],
@ -365,199 +468,109 @@ def run_pso_torch_xfr(
ub_r = [z_bounds[1], y_bounds[1], x_bounds_right[1], azimuth_bounds_r[1], altitude_bounds_r[1]] ub_r = [z_bounds[1], y_bounds[1], x_bounds_right[1], azimuth_bounds_r[1], altitude_bounds_r[1]]
if True or debug: if True or debug:
print(lb_l) for b in (lb_l, ub_l, lb_r, ub_r):
print(ub_l) print('[' + ', '.join(f'{v:10.2f}' for v in b) + ']')
print(lb_r)
print(ub_r)
best_loss_l = float('inf') best_loss_l = float('inf')
best_loss_r = float('inf') best_loss_r = float('inf')
best_position_l = None best_position_l = None
best_position_r = None best_position_r = None
# Left side optimization # L/R 是兩次獨立 PSObounds 不同、目標函數共用),互不依賴:
print(f"\n=== {label_str} 左側 ===") # side='both' 維持原行為(同一次呼叫先 L 後 R
_validate_bounds(lb_l, ub_l, f'{label_str} L') # side='L'/'R' 只跑該側,讓兩側可排到不同 GPU worker。
position_l, loss_l = pso(objective_fn, lb_l, ub_l, # 單側模式的合併輸出3D 圖 + CSV由「較晚完成」的一側在
# 下方「單側收尾」段觸發。
def _side_bounds(s_i):
if s_i == 'L':
return lb_l, ub_l, azimuth_bounds_l, x_bounds_left
return lb_r, ub_r, azimuth_bounds_r, x_bounds_right
def _run_one_side(s_i):
lb_s, ub_s, az_bounds_s, x_bounds_s = _side_bounds(s_i)
tag_cn = '左側' if s_i == 'L' else '右側'
tag = 'LEFT' if s_i == 'L' else 'RIGHT'
print(f"\n=== {label_str} {tag_cn} ===")
_validate_bounds(lb_s, ub_s, f'{label_str} {s_i}')
position, loss = pso(objective_fn, lb_s, ub_s,
# ieqcons=[constraint_y], # ieqcons=[constraint_y],
swarmsize=swarm_size, swarmsize=swarm_size,
omega = omega, omega=omega,
maxiter=max_iter, debug=debug) maxiter=max_iter, debug=debug)
z, x, azimuth, altitude, diameter, length = position_l # 如果需要 retryloss > 0重跑 PSO 取較好者
az_pso, x_pso, L_pso = azimuth, x, length # (原 L/R 各一份註解版 retry此處合併為一式
y = y_indices[round(z), round(x)]
z, x, azimuth, altitude, diameter, length, adopted_l, ref_l = refine_lateral_longer(
z, x, azimuth, altitude, diameter, length,
"L", azimuth_bounds_l, x_bounds_left, y_indices,
image_shape, spacing, device, grid,
cortical_tensor, spine_tensor,
)
y = y_indices[round(z), round(x)]
if adopted_l:
loss_l = ref_l['loss']
print(f"[LEFT] lateral-refine: az {az_pso:.2f} -> {azimuth:.2f}, x {x_pso:.2f} -> {x:.2f}, "
f"L {L_pso:.2f} -> {length:.2f}, in-bone {ref_l['in_bone']*100:.1f}%")
else:
print("[LEFT] lateral-refine: no improvement")
position_l = z, y, x, azimuth, altitude, diameter, length
overlap_l, diameter_l, length_l = eval_overlap_from_position(
position_l, "L", optimize_size, spine_tensor, image_shape, spacing
)
print(f"[LEFT] overlap: {overlap_l*100:.1f}%")
if optimize_size:
print(f"[LEFT] Position: {position_l[:5]}")
print(f"[LEFT] Diameter: {diameter_l} mm (raw: {position_l[5]:.2f})")
print(f"[LEFT] Length: {length_l} mm (raw: {position_l[6]:.2f})")
print(f"[LEFT] Loss: {loss_l}\n")
best_position_l = list(position_l[:5]) + [diameter_l, length_l]
else:
print(f"[LEFT] Position: {position_l}")
best_position_l = position_l
best_loss_l = loss_l
best_overlap_l = overlap_l # 新增
# max_retries = 0 # max_retries = 0
# retries = 0 # retries = 0
# while loss > 0 and retries < max_retries:
# 左側 retryloss 要 <=0 且 overlap >= 0.5 才算過關 # position, loss = pso(objective_fn, lb_s, ub_s,
# while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < max_retries: # swarmsize=swarm_size, maxiter=max_iter)
# position_l, loss_l = pso(objective_fn, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter)
# overlap_l, diameter_l, length_l = eval_overlap_from_position(
# position_l, "L", optimize_size, spine_tensor, image_shape, spacing
# )
# 只要找到更好的 loss或你想用 loss+overlap 綜合排序也行)就更新 best
# 安全版本:優先選「合格解」;沒有合格解時才用 loss 最小的當備案
# candidate_pos = (list(position_l[:5]) + [diameter_l, length_l]) if optimize_size else position_l
# candidate_ok = is_solution_ok(loss_l, overlap_l, OVERLAP_THRESH)
# best_ok = is_solution_ok(best_loss_l, best_overlap_l, OVERLAP_THRESH)
# if candidate_ok and (not best_ok or loss_l < best_loss_l):
# best_position_l = candidate_pos
# best_loss_l = loss_l
# best_overlap_l = overlap_l
# print(f"[LEFT][retry {retries+1}] ✅ ok | loss={loss_l:.4f}, overlap={overlap_l*100:.1f}%")
# elif (not best_ok) and (loss_l < best_loss_l):
# best 還不合格時,先用更小 loss 的當暫存(至少越來越好)
# best_position_l = candidate_pos
# best_loss_l = loss_l
# best_overlap_l = overlap_l
# print(f"[LEFT][retry {retries+1}] ⚠️ not ok | loss improved={loss_l:.4f}, overlap={overlap_l*100:.1f}%")
# else:
# print(f"[LEFT][retry {retries+1}] ❌ no improve | loss={loss_l:.4f}, overlap={overlap_l*100:.1f}%")
# retries += 1 # retries += 1
# Right side optimization z, x, azimuth, altitude, diameter, length = position
print(f"\n=== {label_str} 右側 ===")
_validate_bounds(lb_r, ub_r, f'{label_str} R')
position_r, loss_r = pso(objective_fn, lb_r, ub_r,
# ieqcons=[constraint_y],
swarmsize=swarm_size,
omega = omega,
maxiter=max_iter, debug=debug)
z, x, azimuth, altitude, diameter, length = position_r
az_pso, x_pso, L_pso = azimuth, x, length az_pso, x_pso, L_pso = azimuth, x, length
y = y_indices[round(z), round(x)] y = y_indices[round(z), round(x)]
z, x, azimuth, altitude, diameter, length, adopted_r, ref_r = refine_lateral_longer( z, x, azimuth, altitude, diameter, length, adopted, ref = refine_lateral_longer(
z, x, azimuth, altitude, diameter, length, z, x, azimuth, altitude, diameter, length,
"R", azimuth_bounds_r, x_bounds_right, y_indices, s_i, az_bounds_s, x_bounds_s, y_indices,
image_shape, spacing, device, grid, image_shape, spacing, device, grid,
cortical_tensor, spine_tensor, cortical_tensor, spine_tensor,
vbody_tensor,
) )
y = y_indices[round(z), round(x)] y = y_indices[round(z), round(x)]
if adopted_r: if adopted:
loss_r = ref_r['loss'] loss = ref['loss']
print(f"[RIGHT] lateral-refine: az {az_pso:.2f} -> {azimuth:.2f}, x {x_pso:.2f} -> {x:.2f}, " print(f"[{tag}] lateral-refine: az {az_pso:.2f} -> {azimuth:.2f}, x {x_pso:.2f} -> {x:.2f}, "
f"L {L_pso:.2f} -> {length:.2f}, in-bone {ref_r['in_bone']*100:.1f}%") f"L {L_pso:.2f} -> {length:.2f}, in-bone {ref['in_bone']*100:.1f}%")
else: else:
print("[RIGHT] lateral-refine: no improvement") print(f"[{tag}] lateral-refine: no improvement")
position_r = z, y, x, azimuth, altitude, diameter, length position = z, y, x, azimuth, altitude, diameter, length
overlap_r, diameter_r, length_r = eval_overlap_from_position( overlap_s, d_snap, L_snap = eval_overlap_from_position(
position_r, "R", optimize_size, spine_tensor, image_shape, spacing position, s_i, optimize_size, spine_tensor, image_shape, spacing
) )
print(f"[RIGHT] overlap: {overlap_r*100:.1f}%") print(f"[{tag}] overlap: {overlap_s*100:.1f}%")
if optimize_size: if optimize_size:
# diameter_r, length_r = snap_to_discrete_values(position_r[5], position_r[6]) print(f"[{tag}] Position: {position[:5]}")
diameter_r, length_r = snap_to_discrete_values_xfr(position_r[5], position_r[6]) print(f"[{tag}] Diameter: {d_snap} mm (raw: {position[5]:.2f})")
print(f"[RIGHT] Position: {position_r[:5]}") print(f"[{tag}] Length: {L_snap} mm (raw: {position[6]:.2f})")
print(f"[RIGHT] Diameter: {diameter_r} mm (raw: {position_r[5]:.2f})") print(f"[{tag}] Loss: {loss}\n")
print(f"[RIGHT] Length: {length_r} mm (raw: {position_r[6]:.2f})") best_pos = list(position[:5]) + [d_snap, L_snap]
print(f"[RIGHT] Loss: {loss_r}\n")
best_position_r = list(position_r[:5]) + [diameter_r, length_r]
else: else:
print(f"[RIGHT] Position: {position_r}") print(f"[{tag}] Position: {position}")
print(f"[RIGHT] Loss: {loss_r}\n") best_pos = position
best_position_r = position_r return best_pos, loss, overlap_s
best_loss_r = loss_r sides = ('L', 'R') if side == 'both' else (side,)
best_overlap_r = overlap_r side_time = {}
for s_i in sides:
# 如果需要 retryloss > 0 t0 = time.time()
# max_retries = 10 pos_s, loss_s, _ = _run_one_side(s_i)
# retries = 0 side_time[s_i] = time.time() - t0
if s_i == 'L':
# while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < max_retries: best_position_l, best_loss_l = pos_s, loss_s
# position_r, loss_r = pso(objective_fn, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter) else:
# overlap_r, diameter_r, length_r = eval_overlap_from_position( best_position_r, best_loss_r = pos_s, loss_s
# position_r, "R", optimize_size, spine_tensor, image_shape, spacing
# )
# 只要找到更好的 loss或你想用 loss+overlap 綜合排序也行)就更新 best
# 這裡給你一個更安全的版本:優先選「合格解」;沒有合格解時才用 loss 最小的當備案
# candidate_pos = (list(position_r[:5]) + [diameter_r, length_r]) if optimize_size else position_r
# candidate_ok = is_solution_ok(loss_r, overlap_r, OVERLAP_THRESH)
# best_ok = is_solution_ok(best_loss_r, best_overlap_r, OVERLAP_THRESH)
# if candidate_ok and (not best_ok or loss_r < best_loss_r):
# best_position_r = candidate_pos
# best_loss_r = loss_r
# best_overlap_r = overlap_r
# print(f"[RIGHT][retry {retries+1}] ✅ ok | loss={loss_r:.4f}, overlap={overlap_r*100:.1f}%")
# elif (not best_ok) and (loss_r < best_loss_r):
# best 還不合格時,先用更小 loss 的當暫存(至少越來越好)
# best_position_r = candidate_pos
# best_loss_r = loss_r
# best_overlap_r = overlap_r
# print(f"[RIGHT][retry {retries+1}] ⚠️ not ok | loss improved={loss_r:.4f}, overlap={overlap_r*100:.1f}%")
# else:
# print(f"[RIGHT][retry {retries+1}] ❌ no improve | loss={loss_r:.4f}, overlap={overlap_r*100:.1f}%")
# retries += 1
end_time = time.time() end_time = time.time()
total_time = end_time - start_time total_time = end_time - start_time
# 提取最終的 diameter 和 length # 提取最終的 diameter 和 length
if optimize_size: if optimize_size:
final_diameter_l = best_position_l[5] final_diameter_l = best_position_l[5] if best_position_l is not None else float('nan')
final_length_l = best_position_l[6] final_length_l = best_position_l[6] if best_position_l is not None else float('nan')
final_diameter_r = best_position_r[5] final_diameter_r = best_position_r[5] if best_position_r is not None else float('nan')
final_length_r = best_position_r[6] final_length_r = best_position_r[6] if best_position_r is not None else float('nan')
print(f"\n=== {label_str} 最終結果 ===")
print(f"Left - Diameter: {final_diameter_l} mm, Length: {final_length_l} mm")
print(f"Right - Diameter: {final_diameter_r} mm, Length: {final_length_r} mm")
else: else:
final_diameter_l = diameter final_diameter_l = diameter
final_length_l = length final_length_l = length
final_diameter_r = diameter final_diameter_r = diameter
final_length_r = length final_length_r = length
def _plot_combined(d_l, l_l, d_r, l_r, pos_l, pos_r, total_t):
res_plt_2_torch( res_plt_2_torch(
spine_tensor, spine_tensor,
cortical_tensor, cortical_tensor,
@ -565,19 +578,89 @@ def run_pso_torch_xfr(
image2_path, image2_path,
folder, folder,
label_str, label_str,
final_diameter_l, d_l,
final_length_l, l_l,
final_diameter_r, d_r,
final_length_r, l_r,
best_position_l, pos_l,
best_position_r, pos_r,
swarm_size, swarm_size,
max_iter, max_iter,
total_time, total_t,
spacing, spacing,
CBT, CBT,
device, device,
grid) grid,
)
if side == 'both':
print(f"\n=== {label_str} 最終結果 ===")
print(f"Left - Diameter: {final_diameter_l} mm, Length: {final_length_l} mm")
print(f"Right - Diameter: {final_diameter_r} mm, Length: {final_length_r} mm")
_plot_combined(final_diameter_l, final_length_l,
final_diameter_r, final_length_r,
best_position_l, best_position_r, total_time)
return best_position_l, best_loss_l, best_position_r, best_loss_r, total_time
# ---- 單側收尾:寫該側結果檔;兩側都完成時由較晚的一側觸發合併輸出 ----
# 兩側可能在不同 GPU worker各自把結果寫 <level>_<side>.json
# (先寫 tmp 再 os.replace對端讀到的一定是完整檔。先完成者看不到
# 對端檔就跳過;後完成者看到兩側齊了、搶到 plot lockO_EXCL
# 確保合併輸出只跑一次)才載入對端結果跑 res_plt_2_torch
# 3D 圖 + CSV 兩行,與 'both' 模式相同。json / lock 保留供事後
# 檢查;若該側流程死在 plotting 中段,該 (volume, level) 重跑即可
# run_id 是新的一次,不會互相干擾)。
other = 'R' if side == 'L' else 'L'
own_pos = best_position_l if side == 'L' else best_position_r
own_d = final_diameter_l if side == 'L' else final_diameter_r
own_L = final_length_l if side == 'L' else final_length_r
own_cn = 'Left' if side == 'L' else 'Right'
side_cn = '' if side == 'L' else ''
print(f"\n=== {label_str} 最終結果({side_cn}側) ===")
print(f"{own_cn} - Diameter: {own_d} mm, Length: {own_L} mm")
missing_pair = [n for n, v in (('level', level), ('patient_id', patient_id),
('side_dir', side_dir), ('run_id', run_id)) if not v]
if missing_pair:
print(f"[SIDE] 合併輸出跳過(未提供 {', '.join(missing_pair)}")
else:
patient_dir = os.path.join(side_dir, run_id, patient_id)
own_path = os.path.join(patient_dir, f'{level}_{side}.json')
other_path = os.path.join(patient_dir, f'{level}_{other}.json')
_makedirs_retry(patient_dir)
own = {'position': [float(v) for v in own_pos],
'diameter': float(own_d),
'length': float(own_L),
'time': float(side_time[side])}
tmp_path = f'{own_path}.{os.getpid()}.tmp'
with open(tmp_path, 'w') as f:
json.dump(own, f)
os.replace(tmp_path, own_path)
if not os.path.isfile(other_path):
print(f"[SIDE] {other} 側尚未完成,合併輸出(圖 + CSV待該側完成時觸發")
else:
lock_path = os.path.join(patient_dir, f'{level}.plot.lock')
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
except FileExistsError:
print(f'[SIDE] 合併輸出已由 {other} 側觸發,跳過')
else:
with open(other_path) as f:
other_res = json.load(f)
pos_other = [float(v) for v in other_res['position']]
if side == 'L':
d_l, l_l, pos_l = own_d, own_L, list(own_pos)
d_r, l_r, pos_r = other_res['diameter'], other_res['length'], pos_other
else:
d_r, l_r, pos_r = own_d, own_L, list(own_pos)
d_l, l_l, pos_l = other_res['diameter'], other_res['length'], pos_other
print(f"[SIDE] {side} + {other} 兩側完成 -> 合併輸出(圖 + CSV")
# total_time 用兩側各自耗時相加(各含一次影像載入/平面計算,
# 比原同流程 wall time 略大,僅影響 CSV 的時間欄)
_plot_combined(d_l, l_l, d_r, l_r, pos_l, pos_r,
side_time[side] + other_res['time'])
return best_position_l, best_loss_l, best_position_r, best_loss_r, total_time return best_position_l, best_loss_l, best_position_r, best_loss_r, total_time

View file

@ -10,13 +10,23 @@ def cl_score_torch_xfr(
intersections: int, intersections: int,
diameter: float = None, diameter: float = None,
length: float = None, length: float = None,
cylinder_tip_torch: torch.Tensor = None # 新增:尖端 mask cylinder_tip_torch: torch.Tensor = None, # 新增:尖端 mask
vbody_tensor: torch.Tensor = None # VBODY椎體mask (z,y,x) 0/1None = 不計 VBODY 獎勵
) -> float: ) -> float:
""" """
漸進式評分優先確保找到骨頭再改善細節 漸進式評分優先確保找到骨頭再改善細節
""" """
cyl_total = cylinder_torch.sum().item() cyl_total = cylinder_torch.sum().item()
overlap = ((cortical_tensor == 1) & (cylinder_torch == 1)).sum().item() overlap = ((cortical_tensor == 1) & (cylinder_torch == 1)).sum().item()
# VBODY 獎勵:螺絲落在 (cortical + VBODY) 內的 voxel 每個 100 分
# 100 分項由純 cortical 擴展到 corticalVBODYcortical voxel 分數不變),
# 其中落在 VBODY 的 voxel 每個再加 10 分
if vbody_tensor is not None:
in_vbody = ((vbody_tensor == 1) & (cylinder_torch == 1)).sum().item()
in_corti_vb = (((cortical_tensor == 1) | (vbody_tensor == 1)) & (cylinder_torch == 1)).sum().item()
else:
in_vbody = 0
in_corti_vb = overlap
null_vox = ((cortical_tensor == 0) & (cylinder_torch == 1)).sum().item() null_vox = ((cortical_tensor == 0) & (cylinder_torch == 1)).sum().item()
null_vox2 = ((spine_tensor == 1) & (cylinder_o_torch == 1)).sum().item() null_vox2 = ((spine_tensor == 1) & (cylinder_o_torch == 1)).sum().item()
@ -38,10 +48,12 @@ def cl_score_torch_xfr(
score += 20 * in_bone # 10 實在太低 score += 20 * in_bone # 10 實在太低
score += 100 * overlap score += 100 * overlap
score += 100 * in_corti_vb # (cortical + VBODY) 每 voxel 100 分
score += 50 * in_vbody # VBODY 每 voxel 再加 10 分
# score -= 2000 * max(0, not_in_bone-10) # score -= 2000 * max(0, not_in_bone-10)
# score -= 1000 * max(0, null_vox2-10) # score -= 1000 * max(0, null_vox2-10)
score -= 1000 * not_in_bone score -= 1000 * not_in_bone
score -= 1000 * null_vox2 score -= 2000 * null_vox2
return float(-score) return float(-score)

View file

@ -1,8 +1,15 @@
import numpy as np import numpy as np
import SimpleITK as sitk import SimpleITK as sitk
from scipy.ndimage import center_of_mass, rotate from scipy.ndimage import center_of_mass, rotate, distance_transform_edt, gaussian_filter
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
# 鏡稱面法線的左右LR=x 軸)分量大下。正常左右鏡稱面法線幾乎沿 x
# 若 |a| < 0.7代表搜尋掉進前後coronal面局部極大椎體是厚實塊狀
# 易自鏡射拿高分0019 L3/L4以該平面定義的「中線帶」變成斜切
# 板層,其後側中線缺如判定不可信 → diagnose 回 bad_mirror_plane、
# 不判 no_spinous避免假 [NO-SP] 誤改椎體切分)。
MIRROR_MIN_LR = 0.7
def _best_vertical_split(mask2d): def _best_vertical_split(mask2d):
""" """
binary 2D 陣列最後一軸 = x找讓 mask 與其鏡射重疊最大的垂直線 x = t binary 2D 陣列最後一軸 = x找讓 mask 與其鏡射重疊最大的垂直線 x = t
@ -158,8 +165,83 @@ def best_symmetry_plane(mask_zyx, phi_max=45.0, subsample=7,
'v': (float(v[0]), float(v[1]), float(v[2])), 'v': (float(v[0]), float(v[1]), float(v[2])),
} }
def diagnose_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
deficit_min=4.0, rear_margin=3.0, rear3_max=20,
narrow_frac=0.4):
"""
診斷該椎體的棘突中線後側構造是否缺如
先前手術如 laminectomy / 棘突切除所造成
棘突完整時其尖端是全椎體最後側的骨構造且位於中線因此
deficit = 全骨最後側 AP - 中線帶最後側 AP ~= 0
且中線窄帶 |s| <= narrow_frac*w 在最後側骨 rear_margin 內有充足骨 voxel
切除後中線帶的後側止於殘留後側要素最後側骨偏到側方的
殘餘結構椎板/關節突 deficit + 後側中線空洞
判定no_spinous = deficit >= deficit_min rear3 <= rear3_max
回傳 dict
n_bone : voxel 總數
band_w : 中線帶半寬 wvoxel
deficit : 後側中線缺如量voxel0.5 mm/vox
rear3 : 最後側 3 voxel 中線窄帶的骨 voxel
rear6 : 最後側 6 voxel 中線窄帶的骨 voxel
top_off : axial 投影y,x最後側一行相對鏡稱中線的偏移voxel
no_spinous : True = 後側中線缺如棘突已切除
reason : 'ok' / 'no_spinous' / 'too_few_bone' / 'too_few_midline'
/ 'bad_mirror_plane'
"""
m = np.asarray(mask_zyx) > 0
zz, yy, xx = np.nonzero(m)
out = {'n_bone': int(zz.size), 'band_w': None, 'deficit': 0.0,
'rear3': None, 'rear6': None, 'top_off': None,
'no_spinous': False, 'reason': 'ok'}
if zz.size < 100:
out['reason'] = 'too_few_bone'
return out
a, b, c, d = sym['plane']
if abs(a) < MIRROR_MIN_LR:
# 鏡稱面非左右為主(前後 coronal 局部極大)→ 缺如判定不可信,
# 按 ok 回傳(寧可漏報,不可假切除)
out['reason'] = 'bad_mirror_plane'
return out
X = xx.astype(np.float64)
Y = yy.astype(np.float64)
Z = zz.astype(np.float64)
s = X * a + Y * b + Z * c - d
u_ap = _ap_axis(sym) # +AP = 後側y 小側)
ap = X * u_ap[0] + Y * u_ap[1] + Z * u_ap[2]
w = max(float(min_band), float(band_frac) * float(s.max() - s.min()))
mid = np.abs(s) <= w
aps = ap[mid]
out['band_w'] = float(w)
if aps.size < 50:
out['reason'] = 'too_few_midline'
return out
narrow = np.abs(s) <= w * float(narrow_frac)
ap_max_all = float(ap.max())
deficit = ap_max_all - float(aps.max())
rear3 = int(((ap >= ap_max_all - rear_margin) & narrow).sum())
rear6 = int(((ap >= ap_max_all - 2.0 * rear_margin) & narrow).sum())
out['deficit'] = float(deficit)
out['rear3'] = rear3
out['rear6'] = rear6
proj = m.max(axis=0) # (y, x)
ys_p, xs_p = np.where(proj > 0)
# 只在鏡稱面 x 分量主導時才算 top_off最後側一行對中線的 x 偏移);
# 脊椎在面內大角度旋轉時 |a| 小,(d-b*y-c*z)/a 會放大成無意義的巨值。
if ys_p.size and abs(a) >= 0.5 * max(abs(b), abs(c)):
y_min = int(ys_p.min())
zc = (m.shape[0] - 1) / 2.0
x_mid = (d - b * y_min - c * zc) / a
out['top_off'] = float(abs(xs_p[ys_p == y_min].mean() - x_mid))
if deficit >= float(deficit_min) and rear3 <= int(rear3_max):
out['no_spinous'] = True
out['reason'] = 'no_spinous'
return out
def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0, def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
min_mass_frac=0.05): min_mass_frac=0.05, expand_cap=5):
""" """
best_symmetry_plane 的結果 sym 3D bone mask (z, y, x) 切出棘突 best_symmetry_plane 的結果 sym 3D bone mask (z, y, x) 切出棘突
棘突是中線後側構造利用鏡稱面 a·x+b·y+c·z=d 定義 棘突是中線後側構造利用鏡稱面 a·x+b·y+c·z=d 定義
@ -170,8 +252,14 @@ def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
3) 中線帶的 AP 分佈呈兩大叢椎體在前椎弓/棘突在後 3) 中線帶的 AP 分佈呈兩大叢椎體在前椎弓/棘突在後
以兩叢間的 AP 谷底為界AP >= 谷底 的中線帶 voxel = 棘突含中線椎弓 以兩叢間的 AP 谷底為界AP >= 谷底 的中線帶 voxel = 棘突含中線椎弓
無明顯谷底如骨橋fallback 取中線帶後側 15% 無明顯谷底如骨橋fallback 取中線帶後側 15%
4) 中線帶側緣補回_expand_spinous_runsexpand_cap帶由鏡稱面定義
棘突楔若略偏中線側緣薄條會留在帶外成為 other bone每條 (y, z)
線把棘突 run 向左右各補至多 expand_cap bone voxel
先做後側中線缺如判定diagnose_spinous_processlaminectomy/棘突切除
缺如或谷底後側叢只剩 <5% 殘片時 mode='no_spinous'sp_mask=None
由呼叫端入口面 / 椎體改用對應策略
回傳 (sp_mask (z,y,x) bool, ap_thresh, info dict) 回傳 (sp_mask (z,y,x) bool, ap_thresh, info dict)
資料過少時 sp_mask = Noneinfo['mode'] 說明原因 資料過少或棘突缺如sp_mask = Noneinfo['mode'] 說明原因
""" """
m = np.asarray(mask_zyx) > 0 m = np.asarray(mask_zyx) > 0
zz, yy, xx = np.nonzero(m) zz, yy, xx = np.nonzero(m)
@ -197,6 +285,19 @@ def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
if aps.size < 50: if aps.size < 50:
info['mode'] = 'too_few_midline' info['mode'] = 'too_few_midline'
return None, None, info return None, None, info
# 後側中線缺如laminectomy / 棘突切除):最後側骨偏到側方殘餘、
# 中線帶後側無質量,谷底搜尋沒有意義 → no_spinous
# 呼叫端y_indices 入口面 / segment_vertebral_body改用對應策略。
diag = diagnose_spinous_process(mask_zyx, sym, band_frac=band_frac, min_band=min_band)
info['deficit'] = diag['deficit']
info['rear3'] = diag['rear3']
info['rear6'] = diag['rear6']
if diag['no_spinous']:
info['mode'] = 'no_spinous'
info['top_off'] = diag['top_off']
return None, None, info
lo = int(np.floor(aps.min())) lo = int(np.floor(aps.min()))
hi = int(np.ceil(aps.max())) hi = int(np.ceil(aps.max()))
th = None th = None
@ -216,6 +317,14 @@ def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
if score > best_score: if score > best_score:
best_score, best_i = score, i best_score, best_i = score, i
if best_i is not None: if best_i is not None:
post_frac = (total - csum[best_i + 1]) / total
if post_frac < 0.05 and abs(a) >= MIRROR_MIN_LR:
# 谷底後側叢只剩小殘片(<5% 中線帶質量)=棘突幾乎全除
# (部分切除殘餘):當作缺如,椎體切分不採用此閾值。
# 僅在鏡稱面左右為主時才算數(斜板層時 post_frac 不可信)
info['mode'] = 'no_spinous'
info['post_frac'] = float(post_frac)
return None, None, info
th = float(0.5 * (edges[best_i] + edges[best_i + 1])) th = float(0.5 * (edges[best_i] + edges[best_i + 1]))
mode = 'gap' mode = 'gap'
if th is None: if th is None:
@ -223,19 +332,247 @@ def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
sp_mask = np.zeros(m.shape, dtype=bool) sp_mask = np.zeros(m.shape, dtype=bool)
sel = mid & (ap >= th) sel = mid & (ap >= th)
sp_mask[zz[sel], yy[sel], xx[sel]] = True sp_mask[zz[sel], yy[sel], xx[sel]] = True
info.update(n_sp=int(sel.sum()), ap_thresh=th, mode=mode) sp_mask = _expand_spinous_runs(sp_mask, m, cap=expand_cap)
info.update(n_sp=int(sp_mask.sum()), ap_thresh=th, mode=mode)
return sp_mask, th, info return sp_mask, th, info
def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=3.0,
def _expand_spinous_runs(sp_mask, bone, cap=5):
"""對每條 (y, z) 水平線x = 左右方向),把既有棘突 run 向左右兩側各延伸
至多 cap bone voxel補回落在中線帶 |s|<=w 之外的棘突楔側緣薄條
帶由鏡稱面定義楔略偏中線時側緣會被漏成 other bone
遇到非 bone既有棘突 voxel 或達 cap 即停只延伸既有 run不新建
run該線無棘突時絕不標記椎弓厚處最多少數 cap 棘突基部
相鄰 bone 一併補入屬棘突連續結構"""
out = sp_mask.copy()
if not out.any():
return out
nz, ny, nx = out.shape
for y in range(ny):
sp_y = out[:, y, :]
if not sp_y.any():
continue
bone_y = bone[:, y, :]
for z in np.flatnonzero(sp_y.any(axis=1)):
idx = np.flatnonzero(sp_y[z])
runs = np.split(idx, np.flatnonzero(np.diff(idx) > 1) + 1)
for r in runs:
lo, hi = int(r[0]), int(r[-1])
x = lo - 1
added = 0
while x >= 0 and added < cap and bone_y[z, x] and not out[z, y, x]:
out[z, y, x] = True
x -= 1
added += 1
x = hi + 1
added = 0
while x < nx and added < cap and bone_y[z, x] and not out[z, y, x]:
out[z, y, x] = True
x += 1
added += 1
return out
def smooth_mask_sdf(mask_zyx, sigma=1.5):
"""對 3D boolean mask 做有號距離場SDF高斯平滑
把表面沿法線做近似平均曲率流mean curvature flow圓化凸出的
鋸齒/尖刺填進凹陷的缺口但不會均勻收縮整體形状與直接對 mask
做高斯Blur再 thresh 不同後者會把薄結構一起磨細體積明顯縮小
這是消除三线性旋轉 > 0.5 之後仍剩餘的 voxel 級鋸齒最有效的方法
參數
mask_zyx : (z, y, x) boolean / 0-1 陣列
sigma : 平滑強度voxel1.5 表面積 -4%體積 -0.6%
越大越圓但薄結構棘突尖會被磨損越多
回傳同 shape boolean mask
"""
m = np.asarray(mask_zyx) > 0
if not m.any() or m.all():
return m.copy()
d_in = distance_transform_edt(m)
d_out = distance_transform_edt(~m)
sdf = d_in - d_out
return gaussian_filter(sdf, sigma=float(sigma)) > 0.0
def shape_based_smooth(vol01, sigma=1.5):
"""Shape-based interpolationSBI平滑以 0..1 體積的 0.5 等值面為
物件形狀shape計算有號距離場SDF內正外負在距離域做高斯
插值interpolation再於 0 重新閾值得到圓滑的 smooth mask
smooth_mask_sdf 的差異邊界層0 < v < 1內三线性場值滿足
v 0.5 + dd = voxel 為單位的有號距離故直接以 v - 0.5 替換
EDT 量值得到次體素精確的 SDF層外仍由 distance_transform_edt 給出
精確距離表面梯度連續不再是被 EDT 量化成 ±1 的硬階梯平滑後
的邊界更貼合 soft boundary 的真實位置
參數
vol01 : (z, y, x) 0..1 浮點體積例如旋轉後的 _binary_linear
sigma : 高斯平滑強度voxel意義與 smooth_mask_sdf 相同
回傳 shape boolean mask
"""
v = np.clip(np.asarray(vol01, dtype=np.float32), 0.0, 1.0)
b = v > 0.5
if not b.any() or b.all():
return b.copy()
d_in = distance_transform_edt(b)
d_out = distance_transform_edt(~b)
sdf = d_in - d_out
band = (v > 0) & (v < 1)
sdf[band] = v[band] - 0.5
return gaussian_filter(sdf, sigma=float(sigma)) > 0.0
def _ap_axis(sym):
"""鏡稱面內的前後AP(u,v) 中 |y| 分量大者,正規化成 +AP = 後側y 小側)
本資料系 y 往前遞增 segment_spinous_process 同慣例"""
u = np.array(sym['u'])
v = np.array(sym['v'])
u_ap = u if abs(u[1]) >= abs(v[1]) else v
if u_ap[1] > 0:
u_ap = -u_ap
return u_ap
def _full_ap_valley(aps, min_side_frac=0.15, max_ratio=0.85, smooth=3):
"""終板下骨體 AP 分佈的平滑谷底最深相對谷底sm[i] 對鄰近峰的最小比值),
要求兩側各有 >= min_side_frac 的質量拒絕對小尾巴的偽谷底
回傳 (th None, ratio None)"""
lo = int(np.floor(aps.min()))
hi = int(np.ceil(aps.max()))
if hi - lo < 10:
return None, None
hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
sm = np.convolve(hist, np.ones(smooth) / float(smooth), mode='same')
csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1]
best_i, best_ratio = None, 1.0
for i in range(1, len(hist) - 1):
if not (sm[i] <= sm[i - 1] and sm[i] <= sm[i + 1]):
continue
if csum[i] < min_side_frac * total or (total - csum[i + 1]) < min_side_frac * total:
continue
flank = min(float(sm[:i].max()), float(sm[i + 1:].max()))
if flank <= 0:
continue
ratio = float(sm[i]) / flank
if ratio <= max_ratio and ratio < best_ratio:
best_ratio, best_i = ratio, i
if best_i is None:
return None, None
return float(0.5 * (edges[best_i] + edges[best_i + 1])), best_ratio
def _posterior_min_threshold(aps, rear_frac=0.40, min_side_frac=0.08, smooth=3):
"""AP 分佈後側 `rear_frac` 區間內、平滑直方圖的最小值位置(該處後側質量
比例需 >= min_side_frac棘突缺如椎體中 _full_ap_valley 失敗時的
椎體後側末端 = /弓最薄處備援
回傳 th None"""
lo = int(np.floor(aps.min()))
hi = int(np.ceil(aps.max()))
if hi - lo < 10:
return None
hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
sm = np.convolve(hist, np.ones(smooth) / float(smooth), mode='same')
csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1]
i0 = int(len(hist) * (1.0 - rear_frac))
best_i, best_val = None, np.inf
for i in range(i0, len(hist)):
if (total - csum[i + 1]) < min_side_frac * total:
continue
if sm[i] < best_val:
best_val, best_i = float(sm[i]), i
if best_i is None:
return None
return float(0.5 * (edges[best_i] + edges[best_i + 1]))
def segment_vertebral_body(mask_zyx, sym, endplate, ap_thresh, sp_mode, margin=2.0):
"""
以兩平面從 3D bone mask (z, y, x) 切出椎體前側中央主體塊
1) best_upper_endplate_plane 的上終板面法線朝上 a·x+b·y+c·z=d
只保留終板下側身體側e·p-d <= margin bone voxel
排除跨在終板上方的後側構造椎板/棘突
2) best_symmetry_plane 的鏡稱面提供平面內 AP _ap_axis
+AP = 後側 segment_spinous_process 同慣例AP 切點取三層優先
a) sp_mode == 'gap'中線帶椎管的體/弓谷底 ap_thresh
segment_spinous_process 回傳值最可靠
b) sp_mode == 'no_spinous'棘突已切除中線帶沒有空的體/弓谷底
可用椎體後側末端 = 終板下整體 AP 分佈的局部最小/弓最薄處
切除後殘留後側要素會部分填滿椎管谷底比正常椎體淺
因此放寬 _full_ap_valley 條件min_side_frac 0.15->0.10
max_ratio 0.85->0.95再失敗則取後側 40% 區間的平滑谷底
_posterior_min_threshold
c) 否則終板下整體 AP 分佈的平滑谷底_full_ap_valley
處理中線骨橋等中線搜尋 fallback 的情形
d) 最後 fallbackAP 分佈 55 百分位可能切進椎體內會打 WARNING
椎體 = AP < 切點切點之前側且終板下側的 bone
回傳 (vb_mask (z,y,x) bool, ap_thresh, info dict)
資料不足或上終板面缺位時 vb_mask = Noneinfo['mode'] 說明原因
"""
m = np.asarray(mask_zyx) > 0
zz, yy, xx = np.nonzero(m)
info = {'n_bone': int(zz.size), 'n_vb': 0, 'ap_thresh': None, 'mode': 'empty'}
if zz.size < 50:
return None, None, info
if endplate is None:
info['mode'] = 'no_endplate'
return None, None, info
a, b, c, d = endplate['plane']
X = xx.astype(np.float64)
Y = yy.astype(np.float64)
Z = zz.astype(np.float64)
below = (X * a + Y * b + Z * c - d) <= float(margin)
u_ap = _ap_axis(sym)
ap = X * u_ap[0] + Y * u_ap[1] + Z * u_ap[2]
aps = ap[below]
if aps.size < 50:
info['mode'] = 'too_few_below'
return None, None, info
th, mode = None, 'quantile'
if sp_mode == 'gap' and ap_thresh is not None:
th, mode = float(ap_thresh), 'midline_gap'
elif sp_mode == 'no_spinous':
# 棘突已切除:用放寬條件的整體谷底(見 docstring b
th, _ = _full_ap_valley(aps, min_side_frac=0.10, max_ratio=0.95)
if th is not None:
mode = 'nosp_gap'
else:
th = _posterior_min_threshold(aps)
if th is not None:
mode = 'nosp_post_min'
else:
th, _ = _full_ap_valley(aps)
if th is not None:
mode = 'full_gap'
if th is None:
th = float(np.quantile(aps, 0.55))
sel = below & (ap < th)
if not sel.any():
info['mode'] = 'no_body_voxels'
return None, None, info
vb_mask = np.zeros(m.shape, dtype=bool)
vb_mask[zz[sel], yy[sel], xx[sel]] = True
info.update(n_vb=int(sel.sum()), ap_thresh=th, mode=mode)
return vb_mask, th, info
def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=4.0,
n_iter=500, seed=42): n_iter=500, seed=42):
""" """
3D bone mask (z, y, x) 的最佳上終板近似平面 a·x + b·y + c·z = d 3D bone mask (z, y, x) 的最佳上終板近似平面 a·x + b·y + c·z = d
voxel index 座標(a,b,c) 為朝上的單位法線 voxel index 座標(a,b,c) 為朝上的單位法線
1) 每個 (y, x) 欄位取最上方 bone voxel 作為頂面點僅前側半邊
y >= COM_y避開後方元素 2D superior endplate 定義一致 區域只用該 level 的前側半邊y AP 方向anterior = y 較大側
2) RANSAC 三點擬平面法線限制在與 +z angle_max° 切點 = bone y 範圍中點y >= (y_min+y_max)//2避開後方
計數 ±thresh voxel 內的頂面點為 inlier inlier 最多者 棘突 / 弓根 / 椎管結構 2D superior endplate 定義一致
3) SVD 最小二乘微調
兩階段穩健擬合舊式單次 3D RANSAC 會被側旁結構 / 上層椎體侵入
的頂面污染擬出過陡平面L1 擬到 21°
1) Stage A矢狀線每個 (y, x) 欄位取最上方 bone voxel 作為頂面點
再收斂成 per-y 最上 zRANSAC 擬合矢狀 (y-z) z = s·y + i
取得穩健的矢狀斜率 s
2) Stage B側向線先把頂面點筛到矢狀線 ±max(2·thresh, 5) 帶內
移除離帶的高/低污染點 RANSAC 擬合 (z s·y i) = c·x + k
取得側向斜率 c
3) 平面 z = s·y + c·x + (i+k) 單位法線 (c,s,1)/(1++)
d = (i+k)/(1++)tilt_deg = 法線與 +z 軸夾角
inlier_ratio = 全部前側半頂面點落在平面 ±thresh 的比例
回傳 dict資料不足時回傳 None 回傳 dict資料不足時回傳 None
plane : (a, b, c, d) plane : (a, b, c, d)
normal / offset normal / offset
@ -244,51 +581,67 @@ def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=3.0,
n_points / n_inliers n_points / n_inliers
u, v : 平面內正交方向供繪製用 u, v : 平面內正交方向供繪製用
""" """
from sklearn.linear_model import RANSACRegressor
m = np.asarray(mask_zyx) > 0 m = np.asarray(mask_zyx) > 0
nz, ny, nx = m.shape nz, ny, nx = m.shape
if not m.any():
return None
idx = np.where(m, np.arange(nz)[:, None, None], -1) idx = np.where(m, np.arange(nz)[:, None, None], -1)
ztop = idx.max(axis=0) # (y, x) 每欄最上 z ztop = idx.max(axis=0) # (y, x) 每欄最上 z
y_split = int(round(center_of_mass(m)[1])) if m.sum() else 0 # 前側半邊bone y 範圍中點為切點(前側 = y >= 切點)
y_present = np.where(np.any(m, axis=(0, 2)))[0]
y_split = int((y_present[0] + y_present[-1]) // 2)
# ztop 是 (y, x):條件作用在 y 軸axis 0 # ztop 是 (y, x):條件作用在 y 軸axis 0
sel = (ztop >= 0) & (np.arange(ny)[:, None] >= y_split) sel = (ztop >= 0) & (np.arange(ny)[:, None] >= y_split)
yy, xx = np.where(sel) yy, xx = np.where(sel)
if xx.size < 8: if xx.size < 20:
return None return None
P = np.stack((xx, yy, ztop[yy, xx]), axis=1).astype(np.float64) xa = xx.astype(np.float64)
n = P.shape[0] ya = yy.astype(np.float64)
za = ztop[yy, xx].astype(np.float64)
n = za.size
rng = np.random.default_rng(seed) def _line1d(x1, y1, residual):
cos_min = np.cos(np.deg2rad(angle_max)) """RANSAC 擬線 y = k·x + b失敗時退回普通最小二乘。"""
best_cnt, best_nv, best_d = -1, None, 0.0 try:
for _ in range(n_iter): fit = RANSACRegressor(residual_threshold=float(residual),
i, j, k = rng.choice(n, 3, replace=False) max_trials=int(n_iter),
cr = np.cross(P[j] - P[i], P[k] - P[i]) random_state=int(seed)).fit(x1.reshape(-1, 1), y1)
ln = np.linalg.norm(cr) return float(fit.estimator_.coef_[0]), float(fit.estimator_.intercept_)
if ln < 1e-6: except Exception:
continue k, b = np.polyfit(x1, y1, 1)
nv = cr / ln return float(k), float(b)
if nv[2] < 0:
nv = -nv # ---- Stage A矢狀 (y-z) 線 → 穩健矢狀斜率 s ----
if nv[2] < cos_min: # 法線必須朝上 zt = np.full(ny, -1.0)
continue np.maximum.at(zt, yy, za)
d = float(nv @ P[i]) yv = np.where(zt >= 0)[0]
cnt = int(np.count_nonzero(np.abs(P @ nv - d) <= thresh)) if yv.size < 10:
if cnt > best_cnt:
best_cnt, best_nv, best_d = cnt, nv, d
if best_nv is None:
return None return None
# SVD 微調 s, i = _line1d(yv.astype(np.float64), zt[yv], 5.0)
inl = P[np.abs(P @ best_nv - best_d) <= thresh] if abs(float(np.degrees(np.arctan(s)))) > angle_max:
if inl.shape[0] < 3:
return None return None
mean = inl.mean(axis=0)
_, _, Vt = np.linalg.svd(inl - mean, full_matrices=False) # ---- Stage B固定 s擬側向 (x) 線 (z s·y i) = c·x + k ----
nv = Vt[2] # 合起來 z = s·y + c·x + (i + k) → n0·p = d0n0 = (c, s, 1), d0 = i + k
if nv[2] < 0: res_sag = za - s * ya - i
nv = -nv band = np.abs(res_sag) <= max(2.0 * float(thresh), 5.0)
d = float(nv @ mean) if int(band.sum()) < 20:
dist = np.abs(P @ nv - d) band = np.ones(n, dtype=bool)
inl = P[dist <= thresh] c, k = _line1d(xa[band], res_sag[band], float(thresh))
d0 = i + k
n0 = np.array([-c, -s, 1.0])
norm0 = float(np.linalg.norm(n0))
nv = n0 / norm0
d = float(d0 / norm0)
tilt_deg = float(np.degrees(np.arccos(np.clip(nv[2], -1.0, 1.0))))
if tilt_deg > angle_max:
return None
dist = np.abs(-c * xa - s * ya + za - d0) / norm0
n_in = int(np.count_nonzero(dist <= float(thresh)))
u = np.cross(nv, [1.0, 0.0, 0.0]) u = np.cross(nv, [1.0, 0.0, 0.0])
u = u / np.linalg.norm(u) u = u / np.linalg.norm(u)
v = np.cross(nv, u) v = np.cross(nv, u)
@ -296,10 +649,10 @@ def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=3.0,
'plane': (float(nv[0]), float(nv[1]), float(nv[2]), d), 'plane': (float(nv[0]), float(nv[1]), float(nv[2]), d),
'normal': (float(nv[0]), float(nv[1]), float(nv[2])), 'normal': (float(nv[0]), float(nv[1]), float(nv[2])),
'offset': d, 'offset': d,
'tilt_deg': float(np.degrees(np.arccos(np.clip(nv[2], -1.0, 1.0)))), 'tilt_deg': tilt_deg,
'inlier_ratio': float(inl.shape[0] / n), 'inlier_ratio': float(n_in / n),
'n_points': int(n), 'n_points': int(n),
'n_inliers': int(inl.shape[0]), 'n_inliers': n_in,
'u': (float(u[0]), float(u[1]), float(u[2])), 'u': (float(u[0]), float(u[1]), float(u[2])),
'v': (float(v[0]), float(v[1]), float(v[2])), 'v': (float(v[0]), float(v[1]), float(v[2])),
} }

View file

@ -20,13 +20,89 @@ def save_progress(progress, PROGRESS_FILE):
with open(PROGRESS_FILE, "w") as f: with open(PROGRESS_FILE, "w") as f:
json.dump(progress, f, indent=2) json.dump(progress, f, indent=2)
def process_single_image(image_path, label_path, output_dir_base=None): def process_single_image(image_path, label_path, output_dir_base=None, max_z_spacing=None, allowed_levels=None, min_levels=None, metadata_cache=None):
"""metadata_cache可選需提供 get(name) -> dict|None 與
put(name, dict)dict 可含 spacing=[x,y,z]labels=[label id]
兩者都在 db 裡時整支跳過判定不需讀影像 / label """
image = sitk.ReadImage(image_path)
label = sitk.ReadImage(label_path)
file_name = os.path.basename(image_path) file_name = os.path.basename(image_path)
name = file_name.replace(".nii.gz", "") name = file_name.replace(".nii.gz", "")
# pixel spacing / labels 優先用 metadata db避免每次 run 都讀檔
meta = metadata_cache.get(name) if metadata_cache is not None else None
image = None
label = None
spacing = meta.get("spacing") if meta is not None else None
existing_labels = meta.get("labels") if meta is not None else None
if spacing is None:
image = sitk.ReadImage(image_path)
spacing = [float(v) for v in image.GetSpacing()]
if metadata_cache is not None:
metadata_cache.put(name, {"spacing": spacing})
# z spacing 過大(低解析度掃描)跳過整支 pipeline
if max_z_spacing is not None and spacing[2] > max_z_spacing:
z_spacing = spacing[2]
print(f"z spacing {z_spacing:.2f} mm > {max_z_spacing} mm, "
f"skipping pipeline for {name}")
return {
"processed_labels": [],
"missing_labels": [],
"skipped": True,
"skip_reason": f"z spacing {z_spacing:.2f} mm > {max_z_spacing} mm"
}
if existing_labels is None:
label = sitk.ReadImage(label_path)
# 取得現有 label
lssif = sitk.LabelShapeStatisticsImageFilter()
lssif.Execute(label)
existing_labels = [int(v) for v in lssif.GetLabels()] # 例如 [1,2,3,20,21]
if metadata_cache is not None:
metadata_cache.put(name, {"labels": existing_labels})
else:
print(f"Metadata db hit for {name} (spacing={spacing}, labels={existing_labels})")
print(f"Existing labels in {os.path.basename(label_path)}: {existing_labels}")
allowed_label_list = [n for n in existing_labels
if n in LABEL_MAP and (allowed_levels is None
or LABEL_MAP[n] in allowed_levels)]
# 沒有任何符合 allowed levels如 lumbar的 label不建立輸出資料夾、
# 不做重取樣,整檔跳過(不計入 max_images
if not allowed_label_list:
print(f"No label matches allowed levels {allowed_levels} in {name}, "
f"skipping (no output folder)")
return {
"processed_labels": [],
"missing_labels": [],
"skipped": True,
"skip_reason": f"no label in allowed levels {allowed_levels}"
}
# 符合 allowed levels 的 label 少於 min_levels如 lumbar < 2 層):
# 整支 pipeline 跳過,不建立輸出資料夾(不計入 max_images
if min_levels is not None and len(allowed_label_list) < min_levels:
have = ", ".join(f"{n} ({LABEL_MAP[n]})" for n in allowed_label_list)
print(f"Only {len(allowed_label_list)} allowed-level label(s) [{have}] in {name} "
f"< {min_levels}, skipping pipeline (no output folder)")
return {
"processed_labels": [],
"missing_labels": [],
"skipped": True,
"skip_reason": (f"only {len(allowed_label_list)} label(s) in allowed "
f"levels < {min_levels}")
}
# 進入 pipeline若上面的 spacing / labels 來自 metadata db
# 影像與 label 檔還沒讀,這裡補讀
if image is None:
image = sitk.ReadImage(image_path)
if label is None:
label = sitk.ReadImage(label_path)
# LabelStatisticsImageFilter computes statistics (e.g., mean, minimum, maximum, median) of pixel values in an image, segmented by labels in a corresponding label image. # LabelStatisticsImageFilter computes statistics (e.g., mean, minimum, maximum, median) of pixel values in an image, segmented by labels in a corresponding label image.
lsif = sitk.LabelStatisticsImageFilter() lsif = sitk.LabelStatisticsImageFilter()
lsif.Execute(image, label) lsif.Execute(image, label)
@ -35,13 +111,6 @@ def process_single_image(image_path, label_path, output_dir_base=None):
resampled_sitk_img = resample_img(image, out_spacing=[0.5, 0.5, 0.5], is_label=False) resampled_sitk_img = resample_img(image, out_spacing=[0.5, 0.5, 0.5], is_label=False)
resampled_sitk_lbl = resample_img(label, out_spacing=[0.5, 0.5, 0.5], is_label=True) resampled_sitk_lbl = resample_img(label, out_spacing=[0.5, 0.5, 0.5], is_label=True)
# 取得現有 label
lssif = sitk.LabelShapeStatisticsImageFilter()
lssif.Execute(label)
existing_labels = lssif.GetLabels() # 這會回傳 list例如 [1,2,3,20,21]
print(f"Existing labels in {os.path.basename(label_path)}: {existing_labels}")
# 建立每個檔案的輸出資料夾 # 建立每個檔案的輸出資料夾
file_name = os.path.basename(image_path) file_name = os.path.basename(image_path)
name = file_name.replace(".nii.gz", "") name = file_name.replace(".nii.gz", "")
@ -54,21 +123,47 @@ def process_single_image(image_path, label_path, output_dir_base=None):
for lab in existing_labels: for lab in existing_labels:
f.write(f"{lab}\t{LABEL_MAP.get(lab, 'Unknown')}\n") f.write(f"{lab}\t{LABEL_MAP.get(lab, 'Unknown')}\n")
# 遍歷現有 label 做分割 # 遍歷現有 label 做分割;個別 label 失敗label_map 缺該 label、
# 或 seg_bone 報錯)只跳過該 label不中斷整檔處理
processed = []
skipped = []
for n in existing_labels: for n in existing_labels:
if n not in LABEL_MAP:
print(f"Label {n} not found in label_map, skipping this label (file continues).")
skipped.append(n)
continue
if allowed_levels is not None and LABEL_MAP[n] not in allowed_levels:
print(f"Label {n} ({LABEL_MAP[n]}) not in allowed levels, "
f"skipping this label (file continues).")
skipped.append(n)
continue
try: try:
roi_path, binary_path, roi2_path, cortical_path = seg_bone(n, name, resampled_sitk_img, resampled_sitk_lbl, output_dir, label_map=LABEL_MAP) res = seg_bone(n, name, resampled_sitk_img, resampled_sitk_lbl, output_dir,
for path in [roi_path, binary_path, roi2_path, cortical_path]: label_map=LABEL_MAP, original_label=label)
if res is None:
print(f"Label {n}: empty after largest-CC extraction, skipping this label.")
skipped.append(n)
continue
(roi_path, binary_path, roi2_path, cortical_path, binary_nn_path,
binary_linear_path, smd_path, resampled_path, binary_sdf_path,
binary_erode_path) = res
for path in [roi_path, binary_path, roi2_path, cortical_path,
binary_nn_path, binary_linear_path,
smd_path, resampled_path, binary_sdf_path,
binary_erode_path]:
if path is not None:
standardize_affine(path, output_dir) standardize_affine(path, output_dir)
processed.append(n)
except RuntimeError as e: except RuntimeError as e:
print(f"Label {n} could not be processed, skipping. Error: {e}") print(f"Label {n} could not be processed, skipping. Error: {e}")
skipped.append(n)
return { return {
"processed_labels": [lab for lab in existing_labels], "processed_labels": processed,
"missing_labels": [] "missing_labels": skipped
} }
def process_dataset(image_dir, label_dir, output_dir, labels_to_process=None): def process_dataset(image_dir, label_dir, output_dir, labels_to_process=None, max_images=None, post_process=None, max_z_spacing=None, allowed_levels=None, min_levels=None, metadata_cache=None):
image_files = sorted(glob.glob(os.path.join(image_dir, "*.nii.gz"))) image_files = sorted(glob.glob(os.path.join(image_dir, "*.nii.gz")))
total_files = len(image_files) total_files = len(image_files)
print(f"Total files: {total_files}") print(f"Total files: {total_files}")
@ -77,8 +172,15 @@ def process_dataset(image_dir, label_dir, output_dir, labels_to_process=None):
progress = load_progress(PROGRESS_FILE) progress = load_progress(PROGRESS_FILE)
all_file_summary = [] all_file_summary = []
# max_images 統計「實際進入 pipeline 的檔數」:
# z spacing 超標被跳過的檔不計入配額,繼續掃描後續檔案
processed_count = 0
for idx, image_path in enumerate(image_files, 1): for idx, image_path in enumerate(image_files, 1):
if max_images is not None and processed_count >= max_images:
print(f"Reached max_images={max_images}, stopping early.")
break
file_name = os.path.basename(image_path) file_name = os.path.basename(image_path)
name = file_name.replace(".nii.gz", "") name = file_name.replace(".nii.gz", "")
label_path = os.path.join(label_dir, file_name.replace(".nii.gz", "_seg.nii.gz")) label_path = os.path.join(label_dir, file_name.replace(".nii.gz", "_seg.nii.gz"))
@ -93,25 +195,40 @@ def process_dataset(image_dir, label_dir, output_dir, labels_to_process=None):
print(f"[{idx}/{total_files}] Already finished: {file_name}") print(f"[{idx}/{total_files}] Already finished: {file_name}")
file_summary["current_labels"] = progress[name].get("processed_labels", []) file_summary["current_labels"] = progress[name].get("processed_labels", [])
file_summary["missing_labels"] = progress[name].get("missing_labels", []) file_summary["missing_labels"] = progress[name].get("missing_labels", [])
processed_count += 1
all_file_summary.append(file_summary) all_file_summary.append(file_summary)
continue continue
if not os.path.exists(label_path): if not os.path.exists(label_path):
print(f"[{idx}/{total_files}] Warning: label not found for {file_name}") print(f"[{idx}/{total_files}] Warning: label not found for {file_name}")
file_summary["note"] = "Label file not found" file_summary["note"] = "Label file not found"
processed_count += 1
all_file_summary.append(file_summary) all_file_summary.append(file_summary)
continue continue
try: try:
result = process_single_image(image_path, label_path, output_dir_base=output_dir) result = process_single_image(image_path, label_path, output_dir_base=output_dir,
max_z_spacing=max_z_spacing,
allowed_levels=allowed_levels,
min_levels=min_levels,
metadata_cache=metadata_cache)
# print(result) # print(result)
# exit() # exit()
except Exception as e: except Exception as e:
print(f"[{idx}/{total_files}] Error processing {file_name}: {e}") print(f"[{idx}/{total_files}] Error processing {file_name}: {e}")
file_summary["note"] = f"Error: {e}" file_summary["note"] = f"Error: {e}"
processed_count += 1
all_file_summary.append(file_summary) all_file_summary.append(file_summary)
continue continue
if result.get("skipped"):
# z spacing 超標:不計入 max_images 配額,繼續掃描後續檔案
print(f"[{idx}/{total_files}] Skipped ({result['skip_reason']}): {file_name}")
file_summary["note"] = result["skip_reason"]
all_file_summary.append(file_summary)
continue
processed_count += 1
file_summary["current_labels"] = result["processed_labels"] file_summary["current_labels"] = result["processed_labels"]
file_summary["missing_labels"] = result["missing_labels"] file_summary["missing_labels"] = result["missing_labels"]
all_file_summary.append(file_summary) all_file_summary.append(file_summary)
@ -123,7 +240,15 @@ def process_dataset(image_dir, label_dir, output_dir, labels_to_process=None):
} }
save_progress(progress, PROGRESS_FILE) save_progress(progress, PROGRESS_FILE)
print(f"[{idx}/{total_files}] Finished: {file_name} | Missing labels: {result['missing_labels'] or 'None'}") count_msg = f" | processed {processed_count}/{max_images}" if max_images is not None else ""
print(f"[{idx}/{total_files}] Finished: {file_name} | "
f"Missing labels: {result['missing_labels'] or 'None'}{count_msg}")
if post_process is not None:
try:
post_process(os.path.join(output_dir, name), result["processed_labels"])
except Exception as e:
print(f"[{idx}/{total_files}] post_process error for {name}: {e}")
# --- Summary --- # --- Summary ---
summary_path = os.path.join(output_dir, "all_files_label_summary.txt") summary_path = os.path.join(output_dir, "all_files_label_summary.txt")

View file

@ -2,28 +2,49 @@ import numpy as np
import SimpleITK as sitk import SimpleITK as sitk
from config.constant import LABEL_MAP from config.constant import LABEL_MAP
def resample_img(sitk_image, out_spacing=[0.5, 0.5, 0.5], is_label=False): def resample_img(sitk_image, out_spacing=[0.5, 0.5, 0.5], is_label=False,
interpolator=None, cval=None):
# Resample images to 2mm spacing with SimpleITK """重採樣到 out_spacing完整保留物理範圍邊界 margin 不裁剪):
original_spacing = sitk_image.GetSpacing() - CTis_label=False线性插值本流程是上採樣~0.75-1mm -> 0.5mm
original_size = sitk_image.GetSize() 线性即無漣波的理想重建B-spline 會在身體 margin 等強邊界產生
非物理的 undershoot/overshoot量測~0.5% 體素低於 air floor
out_size = [ -1024 HU最低 -1274沿體輪廓形成黑暈
int(np.round(original_size[0] * (original_spacing[0] / out_spacing[0]))), - 預設填充值 = 影像最小值air而非 GetPixelIDValue()
int(np.round(original_size[1] * (original_spacing[1] / out_spacing[1]))), int16 CT 回傳佔位值 2 軟組織會把 margin 填成軟組織
int(np.round(original_size[2] * (original_spacing[2] / out_spacing[2])))] - 輸出尺寸用 ceil 覆蓋原物理尺寸round 會讓遠端端點被裁 0.25mm
labelis_label=True最近邻填充 0
interpolator明確指定插值器None = is_label Linear / NearestNeighbor
例如 SMD piecewise-linear 場用 sitk.sitkBSpline三阶 B 样条对分段
线性场为精确重建無漣波
cval明確指定填充值None = 上述預設
"""
original_spacing = np.array(sitk_image.GetSpacing(), dtype=float)
original_size = np.array(sitk_image.GetSize())
out_spacing = np.array(out_spacing, dtype=float)
physical = original_size * original_spacing
out_size = [max(1, int(np.ceil(physical[i] / out_spacing[i] - 1e-6)))
for i in range(3)]
resample = sitk.ResampleImageFilter() resample = sitk.ResampleImageFilter()
resample.SetOutputSpacing(out_spacing) resample.SetOutputSpacing(out_spacing.tolist())
resample.SetSize(out_size) resample.SetSize(out_size)
resample.SetOutputDirection(sitk_image.GetDirection()) resample.SetOutputDirection(sitk_image.GetDirection())
resample.SetOutputOrigin(sitk_image.GetOrigin()) resample.SetOutputOrigin(sitk_image.GetOrigin())
resample.SetTransform(sitk.Transform()) resample.SetTransform(sitk.Transform())
resample.SetDefaultPixelValue(sitk_image.GetPixelIDValue())
if is_label: if is_label:
resample.SetInterpolator(sitk.sitkNearestNeighbor) resample.SetInterpolator(interpolator or sitk.sitkNearestNeighbor)
resample.SetDefaultPixelValue(cval if cval is not None else 0)
elif interpolator is not None:
resample.SetInterpolator(interpolator)
# SMD 等 signed 場的填充:預設 0 = 表面層值(比影像 min/max 安全,
# 不會製造假的零穿越環);可用品值可用 cval 覆蓋
resample.SetDefaultPixelValue(float(cval) if cval is not None else 0.0)
else: else:
resample.SetInterpolator(sitk.sitkBSpline) resample.SetInterpolator(sitk.sitkLinear)
# air 值 = 影像最小 HUstatistics 濾波器 streaming 計算,不載入整張 array
stats = sitk.StatisticsImageFilter()
stats.Execute(sitk_image)
resample.SetDefaultPixelValue(float(stats.GetMinimum()))
return resample.Execute(sitk_image) return resample.Execute(sitk_image)

View file

@ -1,6 +1,7 @@
import os import os
import SimpleITK as sitk import SimpleITK as sitk
from config.constant import LABEL_MAP from config.constant import LABEL_MAP
from imaging.resample import resample_img
import numpy as np import numpy as np
""" """
@ -12,7 +13,28 @@ my_map = {1: "L1", 2: "L2", 3: "L3"}
seg_bone(n, name, img, lbl, label_map=my_map) seg_bone(n, name, img, lbl, label_map=my_map)
""" """
def seg_bone(n, name, resampled_sitk_img, resampled_sitk_lbl, output_base=None, label_map=LABEL_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: if output_base==None:
output_base=='Dataset' output_base=='Dataset'
@ -22,87 +44,118 @@ def seg_bone(n, name, resampled_sitk_img, resampled_sitk_lbl, output_base=None,
label_name = label_map[n] label_name = label_map[n]
# lssif = sitk.LabelShapeStatisticsImageFilter() # ============ 原解析度未插值chain ============
# lssif.Execute(resampled_sitk_lbl) # 1. 提取標籤 n 的二值遮罩 (將標籤 n 設為 1其餘為 0),最大連通區域
smd_path = resampled_path = binary_sdf_path = binary_erode_path = None
# if not lssif.HasLabel(n): binary_linear_path = binary_nn_path = None
# raise RuntimeError(f"Label {n} not found") if original_label is not None:
bin_orig = sitk.BinaryThreshold(original_label, n, n, 1, 0)
# bbox2 = lssif.GetBoundingBox(n) cc_orig = _largest_cc_bbox(bin_orig)
if cc_orig is None:
# 1. 提取標籤 n 的二值遮罩 (將標籤 n 設為 1其餘為 0)
binary_mask = sitk.BinaryThreshold(resampled_sitk_lbl, n, n, 1, 0)
# 2. 獲取所有連通區域
# 連通區域濾波器會將 binary_mask 中的不同物體標記為 1, 2, 3...
cc_image = sitk.ConnectedComponent(binary_mask, True) #fullyConnected
# 3. 根據區域大小(像素/體積)重新標記
# RelabelComponent 會按大小排序,最大的物體標籤會被設為 1
relabeled_cc = sitk.RelabelComponent(cc_image, sortByObjectSize=True)
largest_mask = relabeled_cc == 1
# 4. 計算形狀統計信息
shape_stats = sitk.LabelShapeStatisticsImageFilter()
shape_stats.Execute(relabeled_cc)
# 檢查是否有找到任何組件
if shape_stats.GetNumberOfLabels() < 1:
return None return None
largest_orig, bbox_orig = cc_orig
# 5. 獲取最大組件(標籤為 1的邊界框 # _binary.nii.gz原解析度【未插值】遮罩最大连通區域、裁到物件 bbox
# 格式通常為 [x_start, y_start, z_start, x_size, y_size, z_size] # 不重取樣、不插值 —— 原始 label 的忠實二值版本)
bbox2 = shape_stats.GetBoundingBox(1) 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 = sitk.RegionOfInterest(resampled_sitk_img, bbox2[3:], bbox2[:3])
roi_path = os.path.join(output_base, f"{label_name}_roi.nii.gz") roi_path = os.path.join(output_base, f"{label_name}_roi.nii.gz")
sitk.WriteImage(roi, roi_path) sitk.WriteImage(roi, roi_path)
# label2 = sitk.RegionOfInterest(resampled_sitk_lbl, bbox2[3:], bbox2[:3]) return roi_path, binary_path, None, None, binary_nn_path, \
# binary = sitk.BinaryThreshold(label2, lowerThreshold=n, upperThreshold=n, outsideValue=0, insideValue=1) binary_linear_path, smd_path, resampled_path, binary_sdf_path, binary_erode_path
binary = sitk.RegionOfInterest(largest_mask, bbox2[3:], bbox2[:3])
binary_path = os.path.join(output_base, f"{label_name}_binary.nii.gz")
sitk.WriteImage(binary, binary_path)
# roi_pixel_type = roi.GetPixelID()
# binary_cast = sitk.Cast(binary, roi_pixel_type)
# roi2 = roi * binary_cast
roi2 = sitk.Mask(roi, binary)
roi2_path = os.path.join(output_base, f"{label_name}_roi2.nii.gz")
sitk.WriteImage(roi2, roi2_path)
# lsif = sitk.LabelStatisticsImageFilter()
# label2_int = sitk.Cast(label2, sitk.sitkUInt16)
# lsif.Execute(roi2, label2_int)
# labels_in_roi = lsif.GetLabels()
# if n in labels_in_roi:
# roi_hu = sitk.GetArrayFromImage(roi2)
# threshold = np.percentile(roi_hu, 60)
# else:
# threshold = lsif.GetMedian(labels_in_roi[0])
stats = sitk.LabelStatisticsImageFilter()
stats.UseHistogramsOn() # Required for median calculation
stats.Execute(roi, binary)
# Get the median for the region where mask == 1
threshold = stats.GetMedian(1)
cortical = sitk.BinaryThreshold(roi2, lowerThreshold=threshold, upperThreshold=10000, outsideValue=0, insideValue=1)
cortical_path = os.path.join(output_base, f"{label_name}_cortical.nii.gz")
sitk.WriteImage(cortical, cortical_path)
return roi_path, binary_path, roi2_path, cortical_path
""" """
Dataset/ Dataset/
standardized/ standardized/
subject001/ 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 L1_roi.nii.gz
L1_binary.nii.gz
L1_roi2.nii.gz
L1_cortical.nii.gz
L2_roi.nii.gz
L2_binary.nii.gz L2_binary.nii.gz
... ...
""" """

View file

@ -0,0 +1,451 @@
import os
from datetime import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import to_rgba
from matplotlib.lines import Line2D
import numpy as np
import SimpleITK as sitk
from scipy.ndimage import map_coordinates
from imaging.orientation import (best_symmetry_plane, best_upper_endplate_plane,
segment_spinous_process, segment_vertebral_body)
from utils.helpers import get_unique_filepath
# 體積吸收渲染Beer-Lambert與 res_plot_3d 相同:
# 每 voxel 不透明度 = 1 - exp(-mu * voxel_width)
BONE_MU_CORTICAL = 0.02 # 1/mm
BONE_MU_TRABECULAR = 0.005 # 1/mm
BONE_MARKER_SIZE = 3.0 # 骨散點點面積 (pt^2)
BONE_SUBSAMPLE = 1 # 抽稀1 = 全畫)
def set_axes_equal_3d(ax):
"""讓 3D 座標軸等比例,球/立方不變形(同 res_plot_3d"""
x_limits = ax.get_xlim3d()
y_limits = ax.get_ylim3d()
z_limits = ax.get_zlim3d()
x_range = abs(x_limits[1] - x_limits[0])
x_middle = np.mean(x_limits)
y_range = abs(y_limits[1] - y_limits[0])
y_middle = np.mean(y_limits)
z_range = abs(z_limits[1] - z_limits[0])
z_middle = np.mean(z_limits)
plot_radius = 0.5 * max([x_range, y_range, z_range])
ax.set_xlim3d([x_middle - plot_radius, x_middle + plot_radius])
ax.set_ylim3d([y_middle - plot_radius, y_middle + plot_radius])
ax.set_zlim3d([z_middle - plot_radius, z_middle + plot_radius])
try:
ax.set_box_aspect([1, 1, 1])
except AttributeError:
pass
def _load_mask(path):
"""讀取 nifti 二值遮罩 -> (z,y,x) bool缺檔/讀取失敗/空 -> None。"""
if path is None or not os.path.exists(path):
return None
try:
arr = sitk.GetArrayFromImage(sitk.ReadImage(path, sitk.sitkUInt8))
except Exception as e:
print(f"[warn] 讀取失敗 {path}: {e}")
return None
m = arr > 0
if int(m.sum()) == 0:
return None
return m
def _rgba_block(n, color, a):
arr = np.empty((n, 4))
arr[:] = to_rgba(color)
arr[:, 3] = a
return arr
# ======================================================================
# 旋轉對齊index space
# 1) 最佳鏡稱面 normal -> +X使鏡稱面平行 x=0
# 2) 再繞 X 旋轉,使上終板面 normal 的 y 分量为 0與 y 無關)
# 所有向量採 (x, y, z) 順序array 為 (z, y, x)。
# ======================================================================
def _unit(v):
v = np.asarray(v, dtype=float)
n = np.linalg.norm(v)
return v / n if n > 0 else v
def _skew(ax):
ax = np.asarray(ax, dtype=float)
return np.array([[0.0, -ax[2], ax[1]],
[ax[2], 0.0, -ax[0]],
[-ax[1], ax[0], 0.0]])
def rotation_from_to(u, v):
"""3x3 旋轉矩陣,把單位向量 u 映射到單位向量 v最小角旋转"""
u = _unit(u)
v = _unit(v)
c = float(np.clip(np.dot(u, v), -1.0, 1.0))
if c > 1.0 - 1e-12:
return np.eye(3)
if c < -1.0 + 1e-12: # 反平行:繞任一垂直軸轉 180 度
if abs(u[0]) < 0.9:
ax = np.cross([1.0, 0.0, 0.0], u)
else:
ax = np.cross([0.0, 1.0, 0.0], u)
ax = ax / np.linalg.norm(ax)
K = _skew(ax)
return np.eye(3) + 2.0 * (K @ K)
ax = np.cross(u, v)
s = np.linalg.norm(ax)
ax = ax / s
K = _skew(ax)
ang = np.arccos(c)
return np.eye(3) + np.sin(ang) * K + (1.0 - np.cos(ang)) * (K @ K)
def rotation_about_x(theta):
ct, st = np.cos(theta), np.sin(theta)
return np.array([[1.0, 0.0, 0.0],
[0.0, ct, -st],
[0.0, st, ct]])
def compute_normalizing_rotation(mask, sym, symp):
"""回傳 (R, c_xyz)。
R使鏡稱面 normal -> +X上終板面 normal y 分量 -> 0z 分量保持 >0
symp None只做鏡稱面對齊
c_xyz旋轉中心array 中心(x, y, z) 順序
"""
nz, ny, nx = mask.shape
c_xyz = np.array([(nx - 1) / 2.0, (ny - 1) / 2.0, (nz - 1) / 2.0])
n_sym = _unit(sym["normal"])
R1 = rotation_from_to(n_sym, np.array([1.0, 0.0, 0.0]))
if symp is None:
return R1, c_xyz
n_end = _unit(symp["normal"])
w = R1 @ n_end
if abs(w[1]) + abs(w[2]) < 1e-12:
return R1, c_xyz
theta = float(np.arctan2(w[1], w[2]))
R2 = rotation_about_x(theta)
return R2 @ R1, c_xyz
def rotate_volume(arr, R, c_xyz, order=1, cval=0.0):
"""以 R作用於 (x,y,z) 向量)在 index space 旋轉 (z,y,x) 體積,
c_xyz 中心用逆向映射 + 多項式插值order=0 最近邻 / 1 三线性"""
nz, ny, nx = arr.shape
idx = np.indices((nz, ny, nx), dtype=np.float64)
xx, yy, zz = idx[2], idx[1], idx[0]
V = np.stack([xx, yy, zz], axis=0) # (3, nz,ny,nx) = (x,y,z)
c = c_xyz[:, None, None, None]
W = np.tensordot(R.T, V - c, axes=([1], [0])) + c # (3, nz,ny,nx) = (in_x,in_y,in_z)
return map_coordinates(arr, [W[2], W[1], W[0]], order=order, cval=cval, mode="constant")
def rotated_sitk_image(src_image, arr):
"""以相同幾何origin/spacing/direction包裝旋轉後的 array。"""
out = sitk.GetImageFromArray(arr)
out.CopyInformation(src_image)
return out
def rotated_grid(box_size_zyx, R, c_xyz, margin=4):
"""未旋轉緊密裁切grid 的 8 角點經 (R, c_xyz) 剛性旋轉forward
dest = R(src - c) + c後取最小包圍軸對齊盒外擴 margin
回傳 (start, size)皆為 (x, y, z) 模板 index size 含旋轉
擴張旋轉後整顆骨頭保留不被裁切同尺寸旋轉會切掉角落"""
nz, ny, nx = box_size_zyx
c = np.asarray(c_xyz, dtype=np.float64)
Rf = np.asarray(R, dtype=np.float64)
corners = np.array([[x, y, z]
for x in (0.0, float(nx))
for y in (0.0, float(ny))
for z in (0.0, float(nz))], dtype=np.float64)
pts = (corners - c) @ Rf.T + c
lo = np.floor(pts.min(axis=0)).astype(int) - int(margin)
hi = np.ceil(pts.max(axis=0)).astype(int) + int(margin)
return lo, hi - lo + 1
def rotate_volume_to(arr, R, c_xyz, start, size, order=1, cval=0.0):
"""以 R作用於 (x,y,z))逆向映射旋轉 (z,y,x) 體積,繞 c_xyz模板
index 輸出到指定 grid (start, size)模板 index (x, y, z)
arr 外區域以 cval 填充回傳 (size_z, size_y, size_x) 陣列"""
sx, sy, sz = (int(v) for v in start)
nx, ny, nz = (int(v) for v in size)
idx = np.indices((nz, ny, nx), dtype=np.float64)
V = np.stack([idx[2] + sx, idx[1] + sy, idx[0] + sz], axis=0)
c = np.asarray(c_xyz, dtype=np.float64)[:, None, None, None]
Rf = np.asarray(R, dtype=np.float64)
W = np.tensordot(Rf.T, V - c, axes=([1], [0])) + c
return map_coordinates(arr, [W[2], W[1], W[0]], order=order, cval=cval,
mode="constant")
def rotated_sitk_image_at(src_image, arr, start):
"""包裝 arrsize 可不同於 src_imagespacing/direction 同 src_image
origin = start(x, y, z)src_image index 對應的實體座標"""
out = sitk.GetImageFromArray(arr)
out.SetSpacing(src_image.GetSpacing())
d = src_image.GetDirection()
out.SetDirection([d[i * 3 + j] for i in range(3) for j in range(3)])
out.SetOrigin(src_image.TransformIndexToPhysicalPoint(
(int(start[0]), int(start[1]), int(start[2]))))
return out
def _shift_plane_params(plane, start):
"""平面與模板同原點系n·x = d換到陣列原點 = 模板原點 + start 的
局部系x = v + start -> n·v = d - n·start方向 u, v 不變"""
n = np.asarray(plane["plane"][:3], dtype=float)
d = float(plane["plane"][3]) - float(n @ np.asarray(start, dtype=float))
out = dict(plane)
out["plane"] = (float(n[0]), float(n[1]), float(n[2]), d)
return out
def _rotate_plane_params(plane, R, c_xyz):
"""把平面 (a,b,c,d) 與 in-plane 方向 u,v 一併旋轉;回傳同結構 dict。"""
n = np.array(plane["plane"][:3], dtype=float)
d = float(plane["plane"][3])
n_rot = R @ n
d_rot = float(n_rot @ c_xyz) + d - float(n @ c_xyz)
u_rot = R @ np.array(plane["u"], dtype=float)
v_rot = R @ np.array(plane["v"], dtype=float)
out = {
"plane": (n_rot[0], n_rot[1], n_rot[2], d_rot),
"normal": (n_rot[0], n_rot[1], n_rot[2]),
"offset": d_rot,
"u": (u_rot[0], u_rot[1], u_rot[2]),
"v": (v_rot[0], v_rot[1], v_rot[2]),
}
for k in ("ratio", "inlier_ratio", "tilt_deg", "theta_deg", "phi_deg"):
if k in plane:
out[k] = plane[k]
return out
def render_bone_figure(volume_id, level, binary_path, cortical_path,
base_folder="/mnt/1248/open2/cyrou/Output",
spacing=(0.5, 0.5, 0.5), way="CBT",
planes_only=False, output_path=None, rotation=None):
"""繪製單一 (volume, level) 骨頭 X-ray 圖(不畫螺絲)。
四視角預設/axial/coronal/sagittal
內容皮質 vs 鬆質吸收骨椎體(gold)棘突(purple)
中矢狀鏡稱面(orange)上終板面(green)無圓柱/中心線
planes_only=True只畫骨頭 + 中矢狀鏡稱面 + 上終板面
不做棘突 / 椎體VBODY分割
output_path若給定直接存到該路徑含自動加 _1/_2 防覆蓋
否則存到 base_folder/{date}/{volume_id}/{volume_id} {level}_{way}.png
rotation(R, c_xyz)給定時把骨頭點雲與兩個平面都依 R 旋轉 c_xyz
用於畫對齊後rotated的平面圖R 作用於 (x,y,z) 向量
cortical_path皮質遮罩路徑 (z,y,x) 0/1 陣列須與 binary_path grid
None / 缺檔時全部視為鬆質骨
回傳存檔路徑無有效骨頭遮罩時回傳 None
"""
spine = _load_mask(binary_path)
if spine is None:
print(f"[skip] {volume_id} {level}: 無/空骨頭遮罩 {binary_path}")
return None
if isinstance(cortical_path, np.ndarray):
cortical = cortical_path > 0
else:
cortical = _load_mask(cortical_path)
if cortical is None:
cortical = np.zeros_like(spine)
voxel_mm = float(spacing[0])
alpha_cortical = 1.0 - np.exp(-BONE_MU_CORTICAL * voxel_mm)
alpha_trabecular = 1.0 - np.exp(-BONE_MU_TRABECULAR * voxel_mm)
# 骨頭 voxel 拆成皮質 / 鬆質兩組(同 res_plt_2_torch
z_corti, y_corti, x_corti = np.where((spine) & (cortical))
z_trab, y_trab, x_trab = np.where((spine) & (~cortical))
# ---- 方向 / 解剖分割 ----
sym = best_symmetry_plane(spine)
symp = best_upper_endplate_plane(spine)
if planes_only:
# 只做方向平面,不做棘突 / 椎體分割
sp_corti = sp_trab = None
vb_corti = vb_trab = None
sp_info = {}
vb_info = {}
else:
sp_mask, sp_th, sp_info = segment_spinous_process(spine, sym)
if sp_mask is not None and sp_mask.any():
sp_corti = sp_mask[z_corti, y_corti, x_corti]
sp_trab = sp_mask[z_trab, y_trab, x_trab]
else:
sp_corti = sp_trab = None
vb_mask, vb_th, vb_info = segment_vertebral_body(spine, sym, symp, sp_th, sp_info["mode"])
if vb_mask is not None and vb_mask.any():
vb_corti = vb_mask[z_corti, y_corti, x_corti]
vb_trab = vb_mask[z_trab, y_trab, x_trab]
else:
vb_corti = vb_trab = None
# ---- 骨頭點雲(體積吸收)----
x_bone = np.concatenate([x_corti, x_trab])
y_bone = np.concatenate([y_corti, y_trab])
z_bone = np.concatenate([z_corti, z_trab])
bone_rgba = np.concatenate([
_rgba_block(len(x_corti), "lightblue", float(alpha_cortical)),
_rgba_block(len(x_trab), "lightblue", float(alpha_trabecular)),
])
bone_size = np.full(len(x_bone), BONE_MARKER_SIZE)
if BONE_SUBSAMPLE > 1:
x_bone = x_bone[::BONE_SUBSAMPLE]
y_bone = y_bone[::BONE_SUBSAMPLE]
z_bone = z_bone[::BONE_SUBSAMPLE]
bone_rgba = bone_rgba[::BONE_SUBSAMPLE]
bone_size = bone_size[::BONE_SUBSAMPLE]
if vb_corti is not None:
vb_flag = np.concatenate([vb_corti, vb_trab])
if BONE_SUBSAMPLE > 1:
vb_flag = vb_flag[::BONE_SUBSAMPLE]
bone_rgba[vb_flag] = to_rgba("gold", 0.95)
if sp_corti is not None:
sp_flag = np.concatenate([sp_corti, sp_trab])
if BONE_SUBSAMPLE > 1:
sp_flag = sp_flag[::BONE_SUBSAMPLE]
bone_rgba[sp_flag] = to_rgba("purple", 0.95)
# ---- 旋轉對齊(若給定):骨頭點雲與平面同旋轉 ----
if rotation is not None:
R, c_xyz = rotation
P = np.stack([x_bone - c_xyz[0], y_bone - c_xyz[1], z_bone - c_xyz[2]], axis=0)
P = R @ P
x_bone = P[0] + c_xyz[0]
y_bone = P[1] + c_xyz[1]
z_bone = P[2] + c_xyz[2]
sym = _rotate_plane_params(sym, R, c_xyz)
if symp is not None:
symp = _rotate_plane_params(symp, R, c_xyz)
# ---- 中矢狀(鏡稱)平面 ----
_a, _b, _c, _d = sym["plane"]
_n = np.array([_a, _b, _c])
_u = np.array(sym["u"])
_v = np.array(sym["v"])
_p0 = _d * _n
xyz_bone = np.stack([x_bone - _p0[0], y_bone - _p0[1], z_bone - _p0[2]], axis=1)
_pu = xyz_bone @ _u
_pv = xyz_bone @ _v
_U_, _V_ = np.meshgrid(np.linspace(_pu.min(), _pu.max(), 8),
np.linspace(_pv.min(), _pv.max(), 8))
_Xp = _p0[0] + _U_ * _u[0] + _V_ * _v[0]
_Yp = _p0[1] + _U_ * _u[1] + _V_ * _v[1]
_Zp = _p0[2] + _U_ * _u[2] + _V_ * _v[2]
# ---- 上終板平面 ----
_EX = _EY = _EZ = None
if symp is not None:
_ea, _eb, _ec, _ed = symp["plane"]
_en = np.array([_ea, _eb, _ec])
_eu = np.array(symp["u"])
_ev = np.array(symp["v"])
_ep0 = _ed * _en
xz_ep = np.stack([x_bone - _ep0[0], y_bone - _ep0[1], z_bone - _ep0[2]], axis=1)
_pu_ep = xz_ep @ _eu
_pv_ep = xz_ep @ _ev
_EU, _EV = np.meshgrid(np.linspace(_pu_ep.min(), _pu_ep.max(), 8),
np.linspace(_pv_ep.min(), _pv_ep.max(), 8))
_EX = _ep0[0] + _EU * _eu[0] + _EV * _ev[0]
_EY = _ep0[1] + _EU * _eu[1] + _EV * _ev[1]
_EZ = _ep0[2] + _EU * _eu[2] + _EV * _ev[2]
# ---- 圖 ----
fig = plt.figure(figsize=(12, 12))
legend_handles = []
if vb_corti is not None:
legend_handles.append(Line2D([], [], marker="o", ls="", ms=6, color="gold", label="VertebralBody"))
if sp_corti is not None:
legend_handles.append(Line2D([], [], marker="o", ls="", ms=6, color="purple", label="Spinous"))
def _fill_ax(ax):
ax.computed_zorder = False
sc_bone = ax.scatter(x_bone, y_bone, z_bone, c=bone_rgba, s=bone_size, marker="o")
sc_bone.set_zorder(5)
plane = ax.plot_surface(_Xp, _Yp, _Zp, color="orange", alpha=0.30,
linewidth=1.0, edgecolor="orange", rstride=1, cstride=1)
plane.set_zorder(8)
if _EX is not None:
ep = ax.plot_surface(_EX, _EY, _EZ, color="green", alpha=0.35,
linewidth=1.0, edgecolor="green", rstride=1, cstride=1)
ep.set_zorder(7)
ax1 = fig.add_subplot(221, projection="3d")
_fill_ax(ax1)
ax1.set_xlabel("X-axis"); ax1.set_ylabel("Y-axis"); ax1.set_zlabel("Z-axis")
set_axes_equal_3d(ax1)
ax2 = fig.add_subplot(222, projection="3d")
ax2.view_init(elev=90, azim=-90, roll=0)
_fill_ax(ax2)
ax2.set_xlabel("X-axis"); ax2.set_ylabel("Y-axis"); ax2.set_zlabel("Z-axis")
set_axes_equal_3d(ax2)
if legend_handles:
ax2.legend(handles=legend_handles)
ax3 = fig.add_subplot(223, projection="3d")
ax3.view_init(elev=0, azim=90, roll=0)
_fill_ax(ax3)
ax3.set_xlabel("X-axis"); ax3.set_ylabel("Y-axis"); ax3.set_zlabel("Z-axis")
set_axes_equal_3d(ax3)
ax4 = fig.add_subplot(224, projection="3d")
ax4.view_init(elev=0, azim=0, roll=0)
_fill_ax(ax4)
ax4.set_xlabel("X-axis"); ax4.set_ylabel("Y-axis"); ax4.set_zlabel("Z-axis")
set_axes_equal_3d(ax4)
label_str = f"{volume_id} {level}"
base_tag = "planes only" if planes_only else "no screws"
if rotation is not None:
base_tag += ", rotated"
fig.text(0.5, 0.98, f"{label_str} ({base_tag})", ha="center", fontsize=15)
ratio = float(sym.get("ratio", float("nan")))
if planes_only:
ep_ratio = float(symp.get("inlier_ratio", float("nan"))) if symp is not None else float("nan")
info = (f"sym_ratio={ratio:.3f} "
f"endplane_ratio={ep_ratio:.3f} "
f"endplate={'yes' if symp is not None else 'no'}")
else:
info = (f"sym_ratio={ratio:.3f} "
f"spinous={sp_info.get('n_sp', 0)} ({sp_info.get('mode', '?')}) "
f"vertebral_body={vb_info.get('n_vb', 0)} ({vb_info.get('mode', '?')})")
fig.text(0.5, 0.03, info, ha="center", fontsize=8)
fig.tight_layout()
if output_path is not None:
path = get_unique_filepath(output_path)
else:
date_str = datetime.now().strftime("%Y%m%d")
output_folder = os.path.join(base_folder, date_str, volume_id)
output_file = os.path.join(output_folder, f"{volume_id} {level}_{way}.png")
path = get_unique_filepath(output_file)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
fig.savefig(path, dpi=200, bbox_inches="tight")
plt.close(fig)
return path

View file

@ -5,6 +5,8 @@ from matplotlib.colors import to_rgba
from matplotlib.lines import Line2D from matplotlib.lines import Line2D
from mpl_toolkits.mplot3d.art3d import Poly3DCollection from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import os import os
import errno
import time
from datetime import datetime from datetime import datetime
import csv import csv
@ -13,7 +15,7 @@ from core.intersection import center_line_intersections_torch
from core.scoring import cl_score_torch, compute_overlap_ratio_from_cylinder_mask, cl_score_torch_xfr from core.scoring import cl_score_torch, compute_overlap_ratio_from_cylinder_mask, cl_score_torch_xfr
from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour, from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour,
best_symmetry_plane, best_upper_endplate_plane, best_symmetry_plane, best_upper_endplate_plane,
segment_spinous_process) segment_spinous_process, segment_vertebral_body)
from utils.helpers import save_with_unique_name from utils.helpers import save_with_unique_name
# Volume absorption 渲染Beer-Lambert每 voxel 不透明度 = 1 - exp(-mu * voxel_width) # Volume absorption 渲染Beer-Lambert每 voxel 不透明度 = 1 - exp(-mu * voxel_width)
@ -25,6 +27,17 @@ BONE_MU_TRABECULAR = 0.005 # 1/mm → 每 voxel = 1-exp(-0.005*0.5) ~ 0.0025
BONE_MARKER_SIZE = 3.0 # 骨骼散點點面積 (pt^2);略大以補償抽稀後的顆粒感 BONE_MARKER_SIZE = 3.0 # 骨骼散點點面積 (pt^2);略大以補償抽稀後的顆粒感
BONE_SUBSAMPLE = 1 # 每 10 個骨 voxel 畫 1 個降低堆疊不透明度1=全畫) BONE_SUBSAMPLE = 1 # 每 10 個骨 voxel 畫 1 個降低堆疊不透明度1=全畫)
def _retry_robust(fn, *args, retries=20, delay=0.5, **kwargs):
"""對 ENOENT/EEXIST 重試NFS 上輸出樹被外部刪除(或多 worker 併發建同一
output 目錄會有短暫的 ENOENT 窗口重試可恢復其他錯誤直接丟出"""
for i in range(retries):
try:
return fn(*args, **kwargs)
except OSError as e:
if e.errno not in (errno.ENOENT, errno.EEXIST) or i == retries - 1:
raise
time.sleep(delay)
def set_axes_equal_3d(ax): def set_axes_equal_3d(ax):
""" """
Make axes of 3D plot have equal scale so that spheres appear as spheres, Make axes of 3D plot have equal scale so that spheres appear as spheres,
@ -156,8 +169,12 @@ def res_plt_2_torch(
device device
) )
# loss_r = cl_score_torch(cortical_tensor, spine_tensor, cyl_r, cyl_ro, intersections_r) # loss_r = cl_score_torch(cortical_tensor, spine_tensor, cyl_r, cyl_ro, intersections_r)
loss_r = cl_score_torch_xfr(cortical_tensor, spine_tensor, cyl_r, cyl_ro, intersections_r) # loss_r 放在下方 VBODY mask 計算之後:計入與 PSO 目標函數相同的 VBODY voxel 獎勵
if CBT:
azi = float('nan')
alt = float('nan')
else:
azi = azimuth_rotation(image2_path) azi = azimuth_rotation(image2_path)
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False) res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
alt = res['superior']['tilt_angle_deg'] alt = res['superior']['tilt_angle_deg']
@ -191,8 +208,16 @@ def res_plt_2_torch(
sym = best_symmetry_plane(spine_cpu) sym = best_symmetry_plane(spine_cpu)
# 棘突:鏡稱面中線帶(|s|<=w且在 AP 谷底之後側的骨 voxel換不同顏色標示 # 棘突:鏡稱面中線帶(|s|<=w且在 AP 谷底之後側的骨 voxel換不同顏色標示
# 棘突缺如(先前 laminectomy / 棘突切除)時 sp_mask=None不標示。
sp_mask, sp_th, sp_info = segment_spinous_process(spine_cpu, sym) sp_mask, sp_th, sp_info = segment_spinous_process(spine_cpu, sym)
if sp_mask is not None and sp_mask.any(): if sp_info['mode'] == 'no_spinous':
top_off = f"{sp_info['top_off']:.1f}" if sp_info.get('top_off') is not None else 'n/a'
print(f"[NO-SP] 中線後側缺如(先前 laminectomy / 棘突切除): "
f"deficit={sp_info['deficit']:.1f} voxel ({sp_info['deficit'] * 0.5:.1f} mm), "
f"rear3={sp_info['rear3']} voxel, top_off={top_off} voxel "
f"-> 不標示棘突;椎體用放寬後側谷底切分")
sp_corti = sp_trab = None
elif sp_mask is not None and sp_mask.any():
sp_corti = sp_mask[z_corti, y_corti, x_corti] sp_corti = sp_mask[z_corti, y_corti, x_corti]
sp_trab = sp_mask[z_trab, y_trab, x_trab] sp_trab = sp_mask[z_trab, y_trab, x_trab]
sp_n_bone = max(int(spine_cpu.sum()), 1) sp_n_bone = max(int(spine_cpu.sum()), 1)
@ -203,6 +228,34 @@ def res_plt_2_torch(
else: else:
sp_corti = sp_trab = None sp_corti = sp_trab = None
# 上終板平面RANSAC 擬合骨頭頂面(前側)的最佳 a·x + b·y + c·z = d
symp = best_upper_endplate_plane(spine_cpu)
# 椎體:上終板之下(排除跨終板的後側構造)且中線 AP 谷底之前側的骨 voxel
# 換不同顏色標示(見 segment_vertebral_body谷底優先取中線帶
# 中線搜尋 fallback 時退回終板下整體 AP 分佈谷底)
vb_mask, vb_th, vb_info = segment_vertebral_body(spine_cpu, sym, symp,
sp_th, sp_info['mode'])
if vb_mask is not None and vb_mask.any():
vb_corti = vb_mask[z_corti, y_corti, x_corti]
vb_trab = vb_mask[z_trab, y_trab, x_trab]
print(f"[VBODY] n={vb_info['n_vb']} "
f"({100.0 * vb_info['n_vb'] / max(int(spine_cpu.sum()), 1):.1f}% of bone) "
f"AP<{vb_info['ap_thresh']:.1f} mode={vb_info['mode']}")
if vb_info['mode'] == 'quantile':
print(f"[VBODY] WARNING: 未找到體/弓後側谷底,閾值退回 55 百分位 "
f"(可能切進椎體內),建議人工核對該 level 的椎體邊界")
else:
vb_corti = vb_trab = None
print(f"[VBODY] skipped: {vb_info['mode']}")
# loss_r 使用與 PSO 目標函數相同的 VBODY 獎勵(回報分數與優化一致)
vbody_tensor = None
if vb_mask is not None and vb_mask.any():
vbody_tensor = torch.from_numpy(vb_mask.astype(np.uint8)).to(device=device)
loss_r = cl_score_torch_xfr(cortical_tensor, spine_tensor, cyl_r, cyl_ro, intersections_r,
vbody_tensor=vbody_tensor)
# X-ray 外觀:骨頭合成一個半透明體積吸收點雲(下方); # X-ray 外觀:骨頭合成一個半透明體積吸收點雲(下方);
# 螺絲(中心線 + 圓柱 + 入口軌跡延长)合成一個全不透明點雲,永遠畫在骨頭之上 # 螺絲(中心線 + 圓柱 + 入口軌跡延长)合成一個全不透明點雲,永遠畫在骨頭之上
def _rgba_block(n, color, a): def _rgba_block(n, color, a):
@ -227,11 +280,41 @@ def res_plt_2_torch(
z_bone = z_bone[::BONE_SUBSAMPLE] z_bone = z_bone[::BONE_SUBSAMPLE]
bone_rgba = bone_rgba[::BONE_SUBSAMPLE] bone_rgba = bone_rgba[::BONE_SUBSAMPLE]
bone_size = bone_size[::BONE_SUBSAMPLE] bone_size = bone_size[::BONE_SUBSAMPLE]
if sp_corti is not None: # 平面 patch 的範圍用「完整骨頭」(含 VBODY / SP
sp_flag = np.concatenate([sp_corti, sp_trab]) # VBODY 前側是整顆骨最前緣,剔除後綠色終板 patch 會縮小
x_bone_all, y_bone_all, z_bone_all = x_bone, y_bone, z_bone
# VBODY / 棘突拆成獨立上層(在 _fill_ax 內畫):
# 繪製順序 基底骨(5) < VBODY gold(6) < SP purple(6.5) < 終板(7) < 鏡稱面(8) < 螺絲(10)
# axial 視角ax3相機在 +y 前側,椎體 physically 擋在棘突與相機之間,
# 棘突最後畫 -> 紫色不被 gold 遮住
vb_flag = np.zeros(x_bone.shape, dtype=bool)
sp_flag = np.zeros(x_bone.shape, dtype=bool)
if vb_corti is not None:
f = np.concatenate([vb_corti, vb_trab]).astype(bool)
if BONE_SUBSAMPLE > 1: if BONE_SUBSAMPLE > 1:
sp_flag = sp_flag[::BONE_SUBSAMPLE] f = f[::BONE_SUBSAMPLE]
bone_rgba[sp_flag] = to_rgba('purple', 0.95) vb_flag |= f
if sp_corti is not None:
f = np.concatenate([sp_corti, sp_trab]).astype(bool)
if BONE_SUBSAMPLE > 1:
f = f[::BONE_SUBSAMPLE]
sp_flag |= f
vbody_pts = None
vbody_mask = vb_flag & ~sp_flag
if vbody_mask.any():
vbody_pts = (x_bone[vbody_mask], y_bone[vbody_mask], z_bone[vbody_mask])
sp_pts = None
if sp_flag.any():
sp_pts = (x_bone[sp_flag], y_bone[sp_flag], z_bone[sp_flag])
# 基底骨層去掉 VBODY / SP voxel由上面的專屬圖層畫
base_mask = ~(vb_flag | sp_flag)
x_bone = x_bone[base_mask]
y_bone = y_bone[base_mask]
z_bone = z_bone[base_mask]
bone_rgba = bone_rgba[base_mask]
bone_size = bone_size[base_mask]
x_screw = np.concatenate([x_lin1, x_lin2, x_cyl_l1, x_cyl_l2, x_cyl_r1, x_cyl_r2]) x_screw = np.concatenate([x_lin1, x_lin2, x_cyl_l1, x_cyl_l2, x_cyl_r1, x_cyl_r2])
y_screw = np.concatenate([y_lin1, y_lin2, y_cyl_l1, y_cyl_l2, y_cyl_r1, y_cyl_r2]) y_screw = np.concatenate([y_lin1, y_lin2, y_cyl_l1, y_cyl_l2, y_cyl_r1, y_cyl_r2])
@ -242,7 +325,7 @@ def res_plt_2_torch(
_u = np.array(sym['u']) _u = np.array(sym['u'])
_v = np.array(sym['v']) _v = np.array(sym['v'])
_p0 = _d * _n # 平面上最接近原點的點 _p0 = _d * _n # 平面上最接近原點的點
xyz_bone = np.stack([x_bone - _p0[0], y_bone - _p0[1], z_bone - _p0[2]], axis=1) xyz_bone = np.stack([x_bone_all - _p0[0], y_bone_all - _p0[1], z_bone_all - _p0[2]], axis=1)
_pu = xyz_bone @ _u _pu = xyz_bone @ _u
_pv = xyz_bone @ _v _pv = xyz_bone @ _v
_U_, _V_ = np.meshgrid(np.linspace(_pu.min(), _pu.max(), 8), _U_, _V_ = np.meshgrid(np.linspace(_pu.min(), _pu.max(), 8),
@ -251,8 +334,6 @@ def res_plt_2_torch(
_Yp = _p0[1] + _U_ * _u[1] + _V_ * _v[1] _Yp = _p0[1] + _U_ * _u[1] + _V_ * _v[1]
_Zp = _p0[2] + _U_ * _u[2] + _V_ * _v[2] _Zp = _p0[2] + _U_ * _u[2] + _V_ * _v[2]
# 上終板平面RANSAC 擬合骨頭頂面(前側)的最佳 a·x + b·y + c·z = d
symp = best_upper_endplate_plane(spine_cpu)
_EX = _EY = _EZ = None _EX = _EY = _EZ = None
if symp is not None: if symp is not None:
_ea, _eb, _ec, _ed = symp['plane'] _ea, _eb, _ec, _ed = symp['plane']
@ -260,7 +341,7 @@ def res_plt_2_torch(
_eu = np.array(symp['u']) _eu = np.array(symp['u'])
_ev = np.array(symp['v']) _ev = np.array(symp['v'])
_ep0 = _ed * _en _ep0 = _ed * _en
xz_ep = np.stack([x_bone - _ep0[0], y_bone - _ep0[1], z_bone - _ep0[2]], axis=1) xz_ep = np.stack([x_bone_all - _ep0[0], y_bone_all - _ep0[1], z_bone_all - _ep0[2]], axis=1)
_pu_ep = xz_ep @ _eu _pu_ep = xz_ep @ _eu
_pv_ep = xz_ep @ _ev _pv_ep = xz_ep @ _ev
_EU, _EV = np.meshgrid(np.linspace(_pu_ep.min(), _pu_ep.max(), 8), _EU, _EV = np.meshgrid(np.linspace(_pu_ep.min(), _pu_ep.max(), 8),
@ -284,32 +365,32 @@ def res_plt_2_torch(
fig = plt.figure(figsize=(12, 12)) fig = plt.figure(figsize=(12, 12))
# 圖例色塊提高到可讀不透明度(實際渲染仍用真實吸收 alpha
_leg_alpha_c = max(float(alpha_cortical), 0.35)
_leg_alpha_t = max(float(alpha_trabecular), 0.2)
legend_handles = [ legend_handles = [
Line2D([], [], marker='o', ls='', ms=5, color=to_rgba('lightblue', _leg_alpha_c), label='Spine (cortical)'),
Line2D([], [], marker='o', ls='', ms=5, color=to_rgba('lightblue', _leg_alpha_t), label='Spine (trabecular)'),
Line2D([], [], marker='o', ls='', ms=2, color='r', label='Centerline'),
Line2D([], [], marker='o', ls='', ms=6, color='darkcyan', label='Cylinder(L)'), Line2D([], [], marker='o', ls='', ms=6, color='darkcyan', label='Cylinder(L)'),
Line2D([], [], marker='o', ls='', ms=6, color='blue', label='Cylinder(R)'), Line2D([], [], marker='o', ls='', ms=6, color='blue', label='Cylinder(R)'),
Line2D([], [], marker='o', ls='', ms=6, color='pink', label='Entry track (outer)'),
Line2D([], [], color='orange', lw=2, alpha=0.6,
label=f"Mirror plane {sym['plane'][0]:+.2f}x {sym['plane'][1]:+.2f}y {sym['plane'][2]:+.2f}z = {sym['plane'][3]:.1f}"),
] ]
if vb_corti is not None:
legend_handles.append(
Line2D([], [], marker='o', ls='', ms=6, color='gold', label='VertebralBody'))
if sp_corti is not None: if sp_corti is not None:
legend_handles.append(Line2D([], [], marker='o', ls='', ms=6, color='purple', legend_handles.append(
label=f"Spinous process (mirror-plane midline, {sp_info['n_sp']} vox)")) Line2D([], [], marker='o', ls='', ms=6, color='purple', label='SpinousProcess'))
if symp is not None:
legend_handles.append(Line2D([], [], color='green', lw=2, alpha=0.7,
label=f"Upper endplate plane tilt {symp['tilt_deg']:.1f} deg"))
def _fill_ax(ax): def _fill_ax(ax):
# X-ray 外觀:關閉 mplot3d 依深度自動排序 zorder否則半透明骨頭會被重繪到 # X-ray 外觀:關閉 mplot3d 依深度自動排序 zorder否則半透明骨頭會被重繪到
# 螺絲上方);改為固定分層:吸收骨頭 zorder=5全不透明螺絲 zorder=10 # 螺絲上方);改為固定分層:
# 基底骨 zorder=5 < VBODY 6 < SP 6.5 < 終板 7 < 鏡稱面 8 < 螺絲 10
ax.computed_zorder = False ax.computed_zorder = False
sc_bone = ax.scatter(x_bone, y_bone, z_bone, c=bone_rgba, s=bone_size, marker='o') sc_bone = ax.scatter(x_bone, y_bone, z_bone, c=bone_rgba, s=bone_size, marker='o')
sc_bone.set_zorder(5) sc_bone.set_zorder(5)
if vbody_pts is not None:
sc_vb = ax.scatter(vbody_pts[0], vbody_pts[1], vbody_pts[2],
c=to_rgba('gold', 0.95), s=BONE_MARKER_SIZE, marker='o')
sc_vb.set_zorder(6)
if sp_pts is not None:
sc_sp = ax.scatter(sp_pts[0], sp_pts[1], sp_pts[2],
c=to_rgba('purple', 0.95), s=BONE_MARKER_SIZE, marker='o')
sc_sp.set_zorder(6.5)
sc_screw = ax.scatter(x_screw, y_screw, z_screw, c=screw_rgba, s=screw_size, marker='o') sc_screw = ax.scatter(x_screw, y_screw, z_screw, c=screw_rgba, s=screw_size, marker='o')
sc_screw.set_zorder(10) sc_screw.set_zorder(10)
# 中矢狀面(理論左右對稱切分面):半透明橘色平面 x = x_mid # 中矢狀面(理論左右對稱切分面):半透明橘色平面 x = x_mid
@ -366,28 +447,90 @@ Line2D([], [], color='orange', lw=2, alpha=0.6,
user_azimuth_l = 90 - best_position_l[3] - azi user_azimuth_l = 90 - best_position_l[3] - azi
user_azimuth_r = 90 - best_position_r[3] - azi user_azimuth_r = 90 - best_position_r[3] - azi
# 螺絲方向向量(與 generate_cylinder_n_torch 同慣例):
# d = (cos(az)·sin(alt), sin(az)·sin(alt), cos(alt))alt = 相对 +z 的極角
# Azimuth 相对鏡稱面(法線 stheta_v = atan2(sy, sx)
# Azimuth_Lateral = az - theta_v - 90 (面內 AP 軸起的帶號發散角,+ = L 側往外,− = R 側)
# Altitude 相對上終板面(法線 e朝上
# Altitude_Cephalad_Endplate = 90 - (d 與 e 的夾角)
sym_n = np.asarray(sym['normal'], dtype=float)
theta_v = float(np.degrees(np.arctan2(sym_n[1], sym_n[0])))
if symp is not None:
e_n = np.asarray(symp['normal'], dtype=float)
e_n = e_n / np.linalg.norm(e_n)
tau_y = float(np.degrees(np.arctan2(e_n[1], e_n[2])))
tau_x = float(np.degrees(np.arctan2(e_n[0], e_n[2])))
else:
e_n = None
tau_y = float('nan')
tau_x = float('nan')
def _rel_angles(az_deg, alt_deg):
az_r = np.radians(az_deg)
alt_r = np.radians(alt_deg)
d_v = np.array([np.cos(az_r) * np.sin(alt_r),
np.sin(az_r) * np.sin(alt_r),
np.cos(alt_r)])
az_lateral = az_deg - theta_v - 90.0
if e_n is not None:
ang_norm = float(np.degrees(np.arccos(np.clip(d_v @ e_n, -1.0, 1.0))))
alt_cep = 90.0 - ang_norm
else:
alt_cep = float('nan')
return az_lateral, alt_cep
azlat_l, acep_l = _rel_angles(best_position_l[3], best_position_l[4])
azlat_r, acep_r = _rel_angles(best_position_r[3], best_position_r[4])
date_str = datetime.now().strftime("%Y%m%d") date_str = datetime.now().strftime("%Y%m%d")
patient_id = os.path.basename(os.path.dirname(image2_path)) # 旋轉後影像存在 <volume_id>/rotated/ 下parent 是 'rotated'
# 再上一層才是 volume id未旋轉路徑不受影響
img_parent = os.path.dirname(image2_path)
patient_id = os.path.basename(os.path.dirname(img_parent)) \
if os.path.basename(img_parent) == 'rotated' else os.path.basename(img_parent)
output_folder = os.path.join(base_folder, date_str, patient_id) output_folder = os.path.join(base_folder, date_str, patient_id)
os.makedirs(output_folder, exist_ok=True) _retry_robust(os.makedirs, output_folder, exist_ok=True)
csv_path = os.path.join(output_folder, 'output.csv') csv_path = os.path.join(output_folder, 'output.csv')
# 檢查檔案是否存在 (決定是否寫入標題) # 欄位標題 (Header)。CBT 模式下恆為 nan 的 2D 參考欄
file_exists = os.path.isfile(csv_path) # (Azimuth_Diff / Altitude_Diff / User_Azimuth / User_Altitude) 不寫入 CSV。
# 欄位標題 (Header)
headers = [ headers = [
'Label', 'Side', 'Diameter', 'Length', 'Swarm_Size', 'Max_Iter', 'Label', 'Side', 'Diameter', 'Length', 'Swarm_Size', 'Max_Iter',
'Position_XYZ', 'Raw_Azimuth', 'Azimuth_Diff', 'Raw_Altitude', 'Altitude_Diff', 'Position_XYZ', 'Raw_Azimuth', 'Raw_Altitude',
'Intersections', 'Best_Loss', 'cyl_points', 'Overlap_Cortical', 'Overlap_Bone', 'Intersections', 'Best_Loss', 'cyl_points', 'Overlap_Cortical', 'Overlap_Bone',
'Cortical_Bone_Ratio', 'User_Azimuth', 'User_Altitude', 'Total_Time' 'Cortical_Bone_Ratio',
'Sym_Theta_v_deg', 'Endplate_Tau_y_deg', 'Endplate_Tau_x_deg',
'Azimuth_Lateral_deg',
'Altitude_Cephalad_Endplate_deg',
'Total_Time'
] ]
def _fmt(v):
return '' if not np.isfinite(v) else f"{float(v):.2f}"
# 檢查檔案是否存在 (決定是否寫入標題);舊 schema 的檔案按欄位名稱重映射後
# 改以新 Header 重寫(舊檔多出的欄位捨去、缺的欄位補空白),避免 append 欄位錯位
file_exists = os.path.isfile(csv_path)
if file_exists:
with _retry_robust(open, csv_path, newline='') as f:
old_rows = [row for row in csv.reader(f) if any(c.strip() for c in row)]
if not old_rows or old_rows[0] != headers:
old_h = old_rows[0] if old_rows else None
with _retry_robust(open, csv_path, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(headers)
for r in (old_rows[1:] if old_rows else []):
if old_h:
d = dict(zip(old_h, r))
w.writerow([d.get(h, '') for h in headers])
else:
w.writerow(r + [''] * max(0, len(headers) - len(r)))
try: try:
with open(csv_path, 'a', newline='') as csvfile: with _retry_robust(open, csv_path, 'a', newline='') as csvfile:
writer = csv.writer(csvfile) writer = csv.writer(csvfile)
# 如果是新檔案,寫入 Header # 新檔案寫入 Header
if not file_exists: if not file_exists:
writer.writerow(headers) writer.writerow(headers)
@ -402,17 +545,18 @@ Line2D([], [], color='orange', lw=2, alpha=0.6,
# f"({best_position_l[0]:.2f}, {best_position_l[1]:.2f}, {best_position_l[2]:.2f})", # f"({best_position_l[0]:.2f}, {best_position_l[1]:.2f}, {best_position_l[2]:.2f})",
f"({best_position_l[2]:.2f}, {best_position_l[1]:.2f}, {best_position_l[0]:.2f})", f"({best_position_l[2]:.2f}, {best_position_l[1]:.2f}, {best_position_l[0]:.2f})",
f"{best_position_l[3]:.2f}", f"{best_position_l[3]:.2f}",
f"{best_position_l[3]-azi:.2f}",
f"{best_position_l[4]:.2f}", f"{best_position_l[4]:.2f}",
f"{best_position_l[4]-alt:.2f}",
intersections_l, intersections_l,
f"{loss_l:.2f}", f"{loss_l:.2f}",
cyl_points_l, cyl_points_l,
f"{overlap_cortical_l:.2f}", f"{overlap_cortical_l:.2f}",
f"{overlap_vertebral_l:.2f}", f"{overlap_vertebral_l:.2f}",
f"{(overlap_cortical_l/overlap_vertebral_l if overlap_vertebral_l!=0 else 0):.2f}", f"{(overlap_cortical_l/overlap_vertebral_l if overlap_vertebral_l!=0 else 0):.2f}",
f"{user_azimuth_l:.2f}", _fmt(theta_v),
f"{user_altitude_l:.2f}", _fmt(tau_y),
_fmt(tau_x),
_fmt(azlat_l),
_fmt(acep_l),
f"{total_time:.2f}" f"{total_time:.2f}"
]) ])
@ -427,17 +571,18 @@ Line2D([], [], color='orange', lw=2, alpha=0.6,
# f"({best_position_r[0]:.2f}, {best_position_r[1]:.2f}, {best_position_r[2]:.2f})", # f"({best_position_r[0]:.2f}, {best_position_r[1]:.2f}, {best_position_r[2]:.2f})",
f"({best_position_r[2]:.2f}, {best_position_r[1]:.2f}, {best_position_r[0]:.2f})", f"({best_position_r[2]:.2f}, {best_position_r[1]:.2f}, {best_position_r[0]:.2f})",
f"{best_position_r[3]:.2f}", f"{best_position_r[3]:.2f}",
f"{best_position_r[3]-azi:.2f}",
f"{best_position_r[4]:.2f}", f"{best_position_r[4]:.2f}",
f"{best_position_r[4]-alt:.2f}",
intersections_r, intersections_r,
f"{loss_r:.2f}", f"{loss_r:.2f}",
cyl_points_r, cyl_points_r,
f"{overlap_cortical_r:.2f}", f"{overlap_cortical_r:.2f}",
f"{overlap_vertebral_r:.2f}", f"{overlap_vertebral_r:.2f}",
f"{(overlap_cortical_r/overlap_vertebral_r if overlap_vertebral_r!=0 else 0):.2f}", f"{(overlap_cortical_r/overlap_vertebral_r if overlap_vertebral_r!=0 else 0):.2f}",
f"{user_azimuth_r:.2f}", _fmt(theta_v),
f"{user_altitude_r:.2f}", _fmt(tau_y),
_fmt(tau_x),
_fmt(azlat_r),
_fmt(acep_r),
f"{total_time:.2f}" f"{total_time:.2f}"
]) ])
print(f"[CSV Saved] {csv_path}") print(f"[CSV Saved] {csv_path}")
@ -453,21 +598,41 @@ Line2D([], [], color='orange', lw=2, alpha=0.6,
f'Swarm size = {swarm_size}, Iteration = {max_iter}, Total time = {total_time:.2f} s', f'Swarm size = {swarm_size}, Iteration = {max_iter}, Total time = {total_time:.2f} s',
ha='center', fontsize=12 ha='center', fontsize=12
) )
# 角度註記CBT 沒有 2D 參考面user az/alt 為 nanAzimuth/Altitude 直接顯示
# 最佳化出的原始角CSV 的 Raw_Azimuth / Raw_AltitudeTPS 沿用 2D 參考之相對角。
# 另補上相對骨骼的角度(與 CSV 同參數):
# Azimuth_Lateral = 螺絲在鏡稱面內相對 AP 軸的發散角(+ = L 側往外,− = R 側)
# Altitude_Endplate = 螺絲相對上終板面的仰角
# 終板面擬合失敗nan時該段自動略過。
def _fig_angle_segs(az, alt, azlat, acep):
segs = [f'Azimuth = {az:.2f}', f'Altitude = {alt:.2f}']
if np.isfinite(azlat):
segs.append(f'Azimuth_Lateral = {azlat:.2f}')
if np.isfinite(acep):
segs.append(f'Altitude_Endplate = {acep:.2f}')
return ', '.join(segs)
if CBT:
_ang_l = _fig_angle_segs(float(best_position_l[3]), float(best_position_l[4]), azlat_l, acep_l)
_ang_r = _fig_angle_segs(float(best_position_r[3]), float(best_position_r[4]), azlat_r, acep_r)
else:
_ang_l = _fig_angle_segs(user_azimuth_l, user_altitude_l, azlat_l, acep_l)
_ang_r = _fig_angle_segs(user_azimuth_r, user_altitude_r, azlat_r, acep_r)
fig.text( fig.text(
0.5, 0.03, 0.5, 0.03,
# f'Left : Position = ({best_position_l[0]:.2f}, {best_position_l[1]:.2f}, {best_position_l[2]:.2f}), '
f'Left : Position = ({best_position_l[2]:.2f}, {best_position_l[1]:.2f}, {best_position_l[0]:.2f}), ' f'Left : Position = ({best_position_l[2]:.2f}, {best_position_l[1]:.2f}, {best_position_l[0]:.2f}), '
f'Azimuth = {user_azimuth_l:.2f}, Altitude = {user_altitude_l:.2f}, ' f'{_ang_l}, '
f'Intersection = {intersections_l}, Score = {overlap_cortical_l:.2f} / {overlap_vertebral_l:.2f} / {cb_ratio_l:.2f}', f'Intersection = {intersections_l}, Score = {overlap_cortical_l:.2f} / {overlap_vertebral_l:.2f} / {cb_ratio_l:.2f}',
ha='center', fontsize=9 ha='center', fontsize=8
) )
fig.text( fig.text(
0.5, 0.01, 0.5, 0.01,
# f'Right : Position = ({best_position_r[0]:.2f}, {best_position_r[1]:.2f}, {best_position_r[2]:.2f}), '
f'Right : Position = ({best_position_r[2]:.2f}, {best_position_r[1]:.2f}, {best_position_r[0]:.2f}), ' f'Right : Position = ({best_position_r[2]:.2f}, {best_position_r[1]:.2f}, {best_position_r[0]:.2f}), '
f'Azimuth = {user_azimuth_r:.2f}, Altitude = {user_altitude_r:.2f}, ' f'{_ang_r}, '
f'Intersection = {intersections_r}, Score = {overlap_cortical_r:.2f} / {overlap_vertebral_r:.2f} / {cb_ratio_r:.2f}', f'Intersection = {intersections_r}, Score = {overlap_cortical_r:.2f} / {overlap_vertebral_r:.2f} / {cb_ratio_r:.2f}',
ha='center', fontsize=9 ha='center', fontsize=8
) )
fig.tight_layout() fig.tight_layout()
@ -476,7 +641,6 @@ Line2D([], [], color='orange', lw=2, alpha=0.6,
file_name = os.path.basename(image2_path) file_name = os.path.basename(image2_path)
level = file_name.split('_')[0] level = file_name.split('_')[0]
output_folder = os.path.join(base_folder, date_str, patient_id) output_folder = os.path.join(base_folder, date_str, patient_id)
os.makedirs(output_folder, exist_ok=True)
if CBT == True: if CBT == True:
way = 'CBT' way = 'CBT'
@ -484,11 +648,18 @@ Line2D([], [], color='orange', lw=2, alpha=0.6,
else: else:
way = 'TPS' way = 'TPS'
path = save_with_unique_name(output_folder, label_str, way, # 建目錄 + 存檔一起重試輸出樹被外部刪除NFS 刪除競態)時,重試會重建目錄
path = None
def _save_fig_once():
nonlocal path
_retry_robust(os.makedirs, output_folder, exist_ok=True)
# 檔名只用 levelvolume id 已在資料夾名裡,不重複)
path = save_with_unique_name(output_folder, level, way,
diameter_l, length_l, diameter_r, length_r, diameter_l, length_l, diameter_r, length_r,
swarm_size, max_iter) swarm_size, max_iter)
fig.savefig(path, dpi=200, bbox_inches="tight") fig.savefig(path, dpi=200, bbox_inches="tight")
_retry_robust(_save_fig_once)
print("[Saved figure]", path) print("[Saved figure]", path)
plt.close(fig) plt.close(fig)

310
xfr_check_spinous.py Normal file
View file

@ -0,0 +1,310 @@
#!/home/xfr/.conda/envs/cbt/bin/python
"""
檢查各 (volume, level) 的棘突midline 後側構造是否缺如
先前手術如 laminectomy / 棘突切除0005 L50770 L5
判定原理 imaging.orientation.diagnose_spinous_process
棘突完整時尖端是全椎體最後側的骨且位於中線
deficit全骨最後側 - 中線帶最後側 0後側中線窄帶有骨
切除後最後側骨偏到側方殘餘後側中線空洞 deficit + rear3 0
no_spinous = deficit >= 4 vox rear3 <= 20 vox正常 level deficit 恒為 0
兩種模式
quick預設掃全資料集用 兩段式
stage 1axial 投影快速篩檢~0.5s/level寬行椎體行擬合
中線軌跡對傾斜/旋轉穩健不受後側殘片污染找出缺棘突候選
高召回幾乎不漏但因純投影在傾斜脊椎上仍會有少數假候補
stage 2僅對候選跑 full鏡稱面驗證~3s/候選 full 為準給出
最終判定confirmed / false alarm全資料集 ~25min + 候數×3s
full指定單一 volume+level 時預設跑鏡稱面 + 棘突/椎體完整分割
另回報 VBODY 分割 modenosp_gap / nosp_post_min / quantile...
確認該 level 的椎體切分落在椎體後側末端/弓最薄處
識別出的 no_spinous level xfr_debug.py 執行時會打 [NO-SP] 標記
並自動改用放寬後側谷底的椎體切分不再把殘留後側要素當棘突移除
Usage:
python xfr_check_spinous.py # quick全部 volume L1~L5
python xfr_check_spinous.py 0005 # quick該 volume L1~L5
python xfr_check_spinous.py 0005 L5 # full精確單一 level
python xfr_check_spinous.py --full 0005 L5 # 強制 full
python xfr_check_spinous.py --quick # 強制 quick全資料集掃
"""
import csv
import os
import sys
import time
import numpy as np
import SimpleITK as sitk
from imaging.orientation import (best_symmetry_plane, segment_spinous_process,
best_upper_endplate_plane, segment_vertebral_body,
diagnose_spinous_process)
standardized_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr/'
LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5')
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
# 與 diagnose_spinous_process 相同的判定閾值
DEFICIT_MIN = 4.0
REAR3_MAX = 20
# quick中線帶內一行的最小 bone voxel 數(< 此值視為殘片游離 voxel不算「尖端在」
MIN_MID_VOX = 3
# =====================================================================
# quick screen不依賴鏡稱面~0.3s/level
# =====================================================================
def quick_screen(m):
"""axial 投影 (y, x) 快速篩檢(不跑鏡稱面,對傾斜/旋轉穩健):
- 寬行= 行內骨 voxel >= 最大行的 50%即椎體行椎體左右
大致對稱 寬行 x 重心 中線上的點
- 中線軌跡 x(y) = p0 + p1·y 只用寬行椎體行擬合
* 用全部行擬合 laminectomy 後側殘片行污染中線deficit 被吃掉
0005 L5 假正常
* 用固定垂直中線 傾斜棘突尖端被誤判為側方偏移
0555/0609/0637 假切除椎體行兩者都不沾故最穩
- deficit = 最後側骨行 (y_min) 中線軌跡 ± 25% 椎體寬度 band
內有骨的最後側行的行距尖端在中線上 0切除後 切除量
- rear3/rear6 = 最後側 3/6 行內中線軌跡 ± 10% 寬度窄中線帶
voxel
- top_off = 最後側一行 x 重心到中線軌跡的距離
回傳 dict None資料不足"""
proj = m.max(axis=0)
ys_p, xs_p = np.where(proj > 0)
if ys_p.size < 50:
return None
ny = proj.shape[0]
cnt = np.bincount(ys_p, minlength=ny)
max_cnt = int(cnt.max())
if max_cnt < 4:
return None
xsum = np.bincount(ys_p, weights=xs_p.astype(np.float64), minlength=ny)
centroid = xsum / cnt.astype(float)
wide = cnt >= (0.5 * max_cnt) # 椎體行
if int(wide.sum()) < 10:
return None
yv = np.where(wide)[0]
p1, p0 = np.polyfit(yv, centroid[wide], 1) # 中線軌跡(椎體行擬合)
xmax = np.full(ny, -1.0)
xmin = np.full(ny, np.inf)
np.maximum.at(xmax, ys_p, xs_p)
np.minimum.at(xmin, ys_p, xs_p)
span_c = float(np.median(xmax[yv] - xmin[yv]))
if span_c < 10:
return None
line = p0 + p1 * ys_p
band = 0.25 * span_c # central band椎體中央區
narrow = 0.10 * span_c # 窄中線帶
y_min = int(ys_p.min())
# 「中線有骨」需 >= MIN_MID_VOX 個 voxel 才算數:棘突尖端在帶內有
# 多個 voxel切除後的側方殘片在帶內通常只有 1-2 個游離 voxel
# (否則 0005 L5 的單 voxel 殘端會被當成尖端 → 假正常)。
bcnt = np.bincount(ys_p[np.abs(xs_p - line) <= band], minlength=ny)
idx = np.where(bcnt >= MIN_MID_VOX)[0]
y_mid = int(idx.min()) if idx.size else ny - 1
deficit = float(y_mid - y_min)
nn = np.abs(xs_p - line) <= narrow
rear3 = int(((ys_p <= y_min + 3.0) & nn).sum())
rear6 = int(((ys_p <= y_min + 6.0) & nn).sum())
top_off = float(abs(xs_p[ys_p == y_min].mean() - (p0 + p1 * y_min)))
return {'deficit': deficit, 'rear3': rear3, 'rear6': rear6,
'top_off': top_off, 'no_spinous': bool(deficit >= DEFICIT_MIN
and rear3 <= REAR3_MAX)}
# =====================================================================
# full鏡稱面 + 完整棘突/椎體分割(精確,較慢)
# =====================================================================
def full_check(m):
sym = best_symmetry_plane(m)
diag = diagnose_spinous_process(m, sym)
sp_mask, sp_th, sp_info = segment_spinous_process(m, sym)
ep = best_upper_endplate_plane(m)
vb, vb_th, vb_info = segment_vertebral_body(m, sym, ep, sp_th, sp_info['mode'])
row = {'deficit': float(diag['deficit']), 'rear3': diag['rear3'],
'rear6': diag['rear6'], 'top_off': diag['top_off'],
'no_spinous': bool(diag['no_spinous']),
'sp_mode': sp_info['mode'],
'sp_pct': 100.0 * sp_info['n_sp'] / max(int(m.sum()), 1),
'vb_mode': vb_info['mode'],
'vb_pct': (100.0 * vb_info['n_vb'] / max(int(m.sum()), 1))
if vb is not None else None,
'vb_ap_th': vb_th}
return row
# =====================================================================
def load_binary(volume_dir, level):
# _binary.nii.gz 現為原解析度未插值0.5mm 分析優 _binary_sdfSDF 平滑遮罩)
p = os.path.join(volume_dir, f'{level}_binary_sdf.nii.gz')
if not os.path.exists(p):
p = os.path.join(volume_dir, f'{level}_binary.nii.gz')
if not os.path.exists(p):
return None
m = sitk.GetArrayFromImage(sitk.ReadImage(p, sitk.sitkUInt8)) > 0
if int(m.sum()) < 1000 or min(m.shape) < 8:
return None
return m
def check_level(vid, level, use_full):
m = load_binary(os.path.join(standardized_dir, vid), level)
if m is None:
return {'vid': vid, 'level': level, 'note': 'missing/degenerate mask'}
t0 = time.time()
row = quick_screen(m) if not use_full else full_check(m)
row.update(vid=vid.rsplit('.', 1)[-1], level=level,
n_bone=int(m.sum()), sec=round(time.time() - t0, 2))
return row
def parse_args(argv):
use_full = None
pos = []
for a in argv:
if a == '--full':
use_full = True
elif a == '--quick':
use_full = False
else:
pos.append(a)
if len(pos) > 2:
sys.exit('Usage: python xfr_check_spinous.py [--full|--quick] [volume_id] [level]')
vid_arg = pos[0] if len(pos) >= 1 else None
level_arg = pos[1] if len(pos) >= 2 else None
if level_arg and level_arg.upper() not in LEVELS:
sys.exit(f'Invalid level: {level_arg} (choose from {"/".join(LEVELS)})')
if level_arg and not vid_arg:
sys.exit('level requires volume_id')
if use_full is None:
use_full = bool(vid_arg and level_arg) # 指定單 level → 精確模式
return vid_arg, (level_arg.upper() if level_arg else None), use_full
def main():
os.makedirs(LOG_DIR, exist_ok=True)
vid_arg, level_arg, use_full = parse_args(sys.argv[1:])
volumes = [d for d in sorted(os.listdir(standardized_dir))
if os.path.isdir(os.path.join(standardized_dir, d))]
if vid_arg is not None:
key = vid_arg.lower()
vols = [v for v in volumes
if v.lower() == key or v.rsplit('.', 1)[-1] == key]
if not vols:
sys.exit(f'Volume not found: {vid_arg}')
volumes = vols
levels = (level_arg,) if level_arg else LEVELS
tasks = [(v, lvl) for v in volumes for lvl in levels]
mode = ('FULL (mirror plane)' if use_full
else 'QUICK screen -> FULL-verify candidates (2-stage)')
print(f'Mode: {mode} | {len(volumes)} volumes x {len(levels)} levels = {len(tasks)} tasks',
flush=True)
t0 = time.time()
rows = []
candidates = [] # (row 索引, volume 目錄名)
for i, (vid, lvl) in enumerate(tasks, 1):
try:
row = check_level(vid, lvl, use_full)
except Exception as e:
row = {'vid': vid.rsplit('.', 1)[-1], 'level': lvl, 'note': f'error: {e}'}
rows.append(row)
if 'note' in row:
print(f"[{i}/{len(tasks)}] {row['vid']} {row['level']}: {row['note']}", flush=True)
elif use_full:
top_str = 'n/a' if row['top_off'] is None else f"{row['top_off']:.1f}"
print(f"[{i}/{len(tasks)}] {row['vid']} {row['level']}: "
f"deficit={row['deficit']:6.1f} rear3={row['rear3']} "
f"top_off={top_str} {'<<< NO-SPINUS' if row['no_spinous'] else 'ok'} "
f"sp={row['sp_mode']} {row['sp_pct']:.1f}% "
f"vb={row['vb_mode']} {row['vb_pct']:.1f}%", flush=True)
else:
mark = 'candidate' if row['no_spinous'] else 'ok'
print(f"[{i}/{len(tasks)}] {row['vid']} {row['level']}: "
f"deficit={row['deficit']:6.1f} rear3={row['rear3']:4d} "
f"rear6={row['rear6']:5d} top_off={row['top_off']:5.1f} {mark}",
flush=True)
if not use_full and row.get('no_spinous'):
candidates.append((len(rows) - 1, vid))
# ---- Stage 2quick 模式):候選用 full鏡稱面驗證以 full 為準 ----
flagged = []
cleared = []
if use_full:
for r in rows:
if r.get('no_spinous'):
flagged.append(f"{r['vid']} {r['level']}")
elif candidates:
print(f'\n=== stage 2: {len(candidates)} quick candidate(s) -> '
f'full (mirror plane) verification ===', flush=True)
for j, (k, vid_dir) in enumerate(candidates, 1):
row = rows[k]
try:
m = load_binary(os.path.join(standardized_dir, vid_dir), row['level'])
frow = full_check(m) if m is not None else {}
except Exception as e:
frow = {'note': f'verify error: {e}'}
for kk in ('deficit', 'rear3', 'rear6', 'top_off', 'no_spinous',
'sp_mode', 'sp_pct', 'vb_mode', 'vb_pct', 'vb_ap_th'):
if kk in frow:
row[kk] = frow[kk]
if not frow:
flag_str = '-> UNVERIFIED (mask missing/full failed)'
cleared.append(f"{row['vid']} {row['level']} (UNVERIFIED)")
elif row.get('no_spinous'):
flag_str = '<<< NO-SPINUS (confirmed)'
flagged.append(f"{row['vid']} {row['level']}")
else:
flag_str = '-> normal (quick false alarm)'
cleared.append(f"{row['vid']} {row['level']} "
f"(full deficit={row.get('deficit', float('nan')):.1f})")
to = row.get('top_off')
print(f"[verify {j}/{len(candidates)}] {row['vid']} {row['level']}: "
f"deficit={row.get('deficit', float('nan')):6.1f} "
f"rear3={row.get('rear3', '?')} "
f"top_off={'n/a' if to is None else format(to, '5.1f')} "
f"sp={row.get('sp_mode', '?')} "
f"vb={row.get('vb_mode', '?')} {row.get('vb_pct') or 0:.1f}% {flag_str}",
flush=True)
# ---- CSV ----
ts = time.strftime('%Y%m%d_%H%M%S')
fields = ['vid', 'level', 'n_bone', 'deficit', 'rear3', 'rear6', 'top_off',
'no_spinous', 'sp_mode', 'sp_pct', 'vb_mode', 'vb_pct', 'vb_ap_th',
'sec', 'note']
csv_path = os.path.join(LOG_DIR, f'spinous_check_{ts}.csv')
with open(csv_path, 'w', newline='') as f:
wtr = csv.DictWriter(f, fieldnames=fields, extrasaction='ignore')
wtr.writeheader()
for r in rows:
r['vid'] = r.get('vid', '')
wtr.writerow(r)
dt = (time.time() - t0) / 60.0
print('\n' + '=' * 64)
print(f'Done in {dt:.1f} min | {len(rows)} levels checked (mode: {mode})')
if flagged:
print(f'\n>>> {len(flagged)} level(s) with NO SPINOUS PROCESS (prior laminectomy/resection):')
for f_ in flagged:
print(f' {f_}')
print(' (xfr_debug.py 執行這些 level 時會打 [NO-SP] 並自動改用放寬後側谷底的椎體切分)')
else:
print('\nNo missing spinous process detected.')
if cleared:
print(f' (quick 候選、經 full 驗證為正常: {", ".join(cleared)})')
if use_full:
quant = [f"{r['vid']} {r['level']}" for r in rows
if r.get('vb_mode') == 'quantile']
if quant:
print(f'\nWARNING: VBODY 退回 55 百分位切分(可能切進椎體): {", ".join(quant)}')
print(f'CSV: {csv_path}')
if __name__ == '__main__':
main()

View file

@ -1,3 +1,6 @@
#!/home/xfr/.conda/envs/cbt/bin/python
import logging
import os import os
import re import re
import sys import sys
@ -15,7 +18,7 @@ from core.objective import set_global_context
from core.optimizer import run_pso_torch, run_de_torch, run_nm_torch, run_pso_torch_xfr from core.optimizer import run_pso_torch, run_de_torch, run_nm_torch, run_pso_torch_xfr
from imaging.orientation import azimuth_rotation, analyze_vertebral_tilt_contour from imaging.orientation import azimuth_rotation, analyze_vertebral_tilt_contour
standardized_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr/' standardized_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-3/'
azimuth_rotation_dir = '/mnt/1248/open2/cyrou/azimuth_rotation' azimuth_rotation_dir = '/mnt/1248/open2/cyrou/azimuth_rotation'
tilt_contour_dir = '/mnt/1248/open2/cyrou/tilt_contour' tilt_contour_dir = '/mnt/1248/open2/cyrou/tilt_contour'
@ -25,6 +28,12 @@ LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5')
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs') LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
# 單側任務的中繼結果(<run_id>/<volume_id>/<level>_<side>.json + plot lock
# 同一 level 的 L/R 可跑在不同 GPU較晚完成的一側讀到兩側結果後跑合併輸出
SIDE_RESULT_DIR = os.path.join(LOG_DIR, 'side_results')
logger = logging.getLogger('xfr_debug')
# 目前任務標記(每個 worker 流程各自一份),讓交錯的 log 可以歸屬到 (volume, level, side) # 目前任務標記(每個 worker 流程各自一份),讓交錯的 log 可以歸屬到 (volume, level, side)
_TASK_TAG = {'vid': None, 'level': None, 'side': None} _TASK_TAG = {'vid': None, 'level': None, 'side': None}
@ -32,10 +41,10 @@ _TASK_TAG = {'vid': None, 'level': None, 'side': None}
_NP_WRAP_RE = re.compile(r'\bnp\.[A-Za-z_][A-Za-z0-9_]*\(([^()]*)\)') _NP_WRAP_RE = re.compile(r'\bnp\.[A-Za-z_][A-Za-z0-9_]*\(([^()]*)\)')
def set_task_tag(volume_id, level): def set_task_tag(volume_id, level, side=None):
_TASK_TAG['vid'] = volume_id _TASK_TAG['vid'] = volume_id
_TASK_TAG['level'] = level _TASK_TAG['level'] = level
_TASK_TAG['side'] = None _TASK_TAG['side'] = side
class _Tee: class _Tee:
@ -123,6 +132,15 @@ def setup_tee(log_path):
sys.stderr = _Tee(sys.stderr, log_fh) sys.stderr = _Tee(sys.stderr, log_fh)
def _setup_logging():
"""须在 setup_tee 之後呼叫,讓 handler 寫入 Tee同時進 console 與 log 檔)"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def get_device(gpu_id=None): def get_device(gpu_id=None):
if torch.cuda.is_available(): if torch.cuda.is_available():
if gpu_id is None: if gpu_id is None:
@ -135,10 +153,10 @@ def get_device(gpu_id=None):
max_free = free_mem max_free = free_mem
gpu_id = i gpu_id = i
device = torch.device(f"cuda:{gpu_id}") device = torch.device(f"cuda:{gpu_id}")
print(f"Using GPU {gpu_id}: {torch.cuda.get_device_name(gpu_id)}") logger.info(f"Using GPU {gpu_id}: {torch.cuda.get_device_name(gpu_id)}")
else: else:
device = torch.device("cpu") device = torch.device("cpu")
print("CUDA not available, using CPU") logger.info("CUDA not available, using CPU")
return device return device
def debug_orientation(volume_id, level): def debug_orientation(volume_id, level):
@ -146,9 +164,10 @@ def debug_orientation(volume_id, level):
volume_dir = os.path.join(standardized_dir, volume_id) volume_dir = os.path.join(standardized_dir, volume_id)
cortical_path = os.path.join(volume_dir, f'{level}_cortical.nii.gz') # _binary.nii.gz 現為原解析度0.5mm 用 _binary_sdfSDF 平滑遮罩)
binary_path = os.path.join(volume_dir, f'{level}_binary.nii.gz') sdf = os.path.join(volume_dir, f'{level}_binary_sdf.nii.gz')
roi_path = os.path.join(volume_dir, f'{level}_roi2.nii.gz') binary_path = sdf if os.path.exists(sdf) \
else os.path.join(volume_dir, f'{level}_binary.nii.gz')
# azi = azimuth_rotation(binary_path) # azi = azimuth_rotation(binary_path)
# res = analyze_vertebral_tilt_contour(binary_path, edge_type='superior', show_plot=False, debug=False) # res = analyze_vertebral_tilt_contour(binary_path, edge_type='superior', show_plot=False, debug=False)
@ -156,11 +175,11 @@ def debug_orientation(volume_id, level):
res = analyze_vertebral_tilt_contour(binary_path, edge_type='superior', show_plot=True, debug=False, save_plt=True, output_path=f'{tilt_contour_dir}/{level}_{volume_id}.png') res = analyze_vertebral_tilt_contour(binary_path, edge_type='superior', show_plot=True, debug=False, save_plt=True, output_path=f'{tilt_contour_dir}/{level}_{volume_id}.png')
alt = res['superior']['tilt_angle_deg'] alt = res['superior']['tilt_angle_deg']
print(binary_path) logger.info(binary_path)
# print(f'Azimuth: {azi}, Alt: {alt}') # print(f'Azimuth: {azi}, Alt: {alt}')
print(f'Alt: {alt}') logger.info(f'Alt: {alt}')
def debug_pso(volume_id, level, device=None): def debug_pso(volume_id, level, device=None, side='both', run_id=None):
# ====== PSO ====== # ====== PSO ======
swarm_size = 100 swarm_size = 100
max_iter = 100 max_iter = 100
@ -174,10 +193,16 @@ def debug_pso(volume_id, level, device=None):
CBT = True CBT = True
volume_dir = os.path.join(standardized_dir, volume_id) volume_dir = os.path.join(standardized_dir, volume_id)
# _cortical 現只產出旋轉版rotated/ 子資料夾_roi2 不再存檔,
# 改用旋轉後 _roiCT
rotated_dir = os.path.join(volume_dir, 'rotated')
cortical_path = os.path.join(rotated_dir, f'{level}_cortical.nii.gz')
binary_path = os.path.join(rotated_dir, f'{level}_binary_sdf.nii.gz')
roi_path = os.path.join(rotated_dir, f'{level}_roi.nii.gz')
cortical_path = os.path.join(volume_dir, f'{level}_cortical.nii.gz') missing = [p for p in (cortical_path, binary_path, roi_path) if not os.path.exists(p)]
binary_path = os.path.join(volume_dir, f'{level}_binary.nii.gz') if missing:
roi_path = os.path.join(volume_dir, f'{level}_roi2.nii.gz') raise ValueError(f'{volume_id} {level}: missing {missing}')
cortical_image = sitk.ReadImage(cortical_path) cortical_image = sitk.ReadImage(cortical_path)
binary_image = sitk.ReadImage(binary_path) binary_image = sitk.ReadImage(binary_path)
@ -222,6 +247,11 @@ def debug_pso(volume_id, level, device=None):
device=device, device=device,
optimize_size=True, optimize_size=True,
grid=grid, grid=grid,
side=side,
level=level,
patient_id=volume_id,
side_dir=SIDE_RESULT_DIR,
run_id=run_id,
) )
# exit() # exit()
@ -239,43 +269,62 @@ def list_gpu_ids():
return [] return []
def gpu_worker(gpu_id, log_path, task_queue, result_queue): def gpu_worker(gpu_id, log_path, run_id, task_queue, result_queue):
"""每張 GPU 一個工作流程:先鎖死該 GPU再從共享隊列領 (volume, level) 任務""" """每張 GPU 一個工作流程:先鎖死該 GPU再從共享隊列領 (volume, level) 任務"""
# worker 是獨立流程,要自己把輸出 tee 到 log 檔 # worker 是獨立流程,要自己把輸出 tee 到 log 檔
setup_tee(log_path) setup_tee(log_path)
_setup_logging()
# 必須在任何 torch.cuda 呼叫前設定 # 必須在任何 torch.cuda 呼叫前設定
os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id) os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id)
torch.cuda.set_device(0) torch.cuda.set_device(0)
device = torch.device('cuda:0') device = torch.device('cuda:0')
print(f'=== [GPU {gpu_id}] worker started ===', flush=True) logger.info(f'=== [GPU {gpu_id}] worker started ===')
while True: while True:
# 注意multiprocessing.Queue 沒有 task_done()(只有 queue.Queue 有),別加回來 # 注意multiprocessing.Queue 沒有 task_done()(只有 queue.Queue 有),別加回來
item = task_queue.get() item = task_queue.get()
if item is None: if item is None:
break break
volume_id, level = item volume_id, level, side = item
set_task_tag(volume_id, level) # tag 沿用 _Tee 的 LEFT/RIGHT 寫法('L'/'R' 是 optimizer 的 side 值)
set_task_tag(volume_id, level, 'LEFT' if side == 'L' else 'RIGHT')
try: try:
debug_pso(volume_id, level, device) debug_pso(volume_id, level, device, side=side, run_id=run_id)
result_queue.put(('task', gpu_id, volume_id, level, True, '')) result_queue.put(('task', gpu_id, volume_id, level, side, True, ''))
except Exception as e: except Exception as e:
print(f'[GPU {gpu_id}] Error in {volume_id} {level}: {e}', flush=True) logger.error(f'[GPU {gpu_id}] Error in {volume_id} {level} {side}: {e}')
result_queue.put(('task', gpu_id, volume_id, level, False, str(e))) result_queue.put(('task', gpu_id, volume_id, level, side, False, str(e)))
result_queue.put(('done', gpu_id)) result_queue.put(('done', gpu_id))
print(f'=== [GPU {gpu_id}] worker finished ===', flush=True) logger.info(f'=== [GPU {gpu_id}] worker finished ===')
def _run_sequential(tasks): def _run_sequential(tasks, run_id):
"""沒有或只有一張GPU 時的回退:單流程串行""" """沒有或只有一張GPU 時的回退:單流程串行"""
device = get_device() device = get_device()
for volume_id, level in tasks: for volume_id, level, side in tasks:
set_task_tag(volume_id, level) set_task_tag(volume_id, level, 'LEFT' if side == 'L' else 'RIGHT')
try: try:
debug_pso(volume_id, level, device) debug_pso(volume_id, level, device, side=side, run_id=run_id)
except Exception as e: except Exception as e:
print(f'Error in {volume_id} {level}: {e}') logger.error(f'Error in {volume_id} {level} {side}: {e}')
USAGE = 'Usage: python xfr_debug.py [volume_id] [level]'
def parse_args(argv):
"""volume_id 可用完整 ID 或末段(如 0005level 為 LEVELS 之一L1~L5
兩者可省略全部level 必須搭配 volume_id 使用"""
vid_arg = argv[0] if len(argv) >= 1 else None
level_arg = argv[1] if len(argv) >= 2 else None
if len(argv) > 2:
sys.exit(f'{USAGE}\nToo many arguments')
if level_arg and level_arg.upper() not in LEVELS:
sys.exit(f'{USAGE}\nInvalid level: {level_arg} (choose from {"/".join(LEVELS)})')
if level_arg and not vid_arg:
sys.exit(f'{USAGE}\nlevel requires volume_id')
return vid_arg, (level_arg.upper() if level_arg else None)
def main(): def main():
@ -289,31 +338,48 @@ def main():
# 要處理的 volume 數(並行模式下是「最多嘗試的 volume 數」) # 要處理的 volume 數(並行模式下是「最多嘗試的 volume 數」)
MAX_SUCCESSFUL_VOLUMES = 100 MAX_SUCCESSFUL_VOLUMES = 100
MAX_SUCCESSFUL_VOLUMES = 1 MAX_SUCCESSFUL_VOLUMES = 10
# log 檔console 與檔案同時輸出;各 GPU worker 也會 append 進同一個檔) # log 檔console 與檔案同時輸出;各 GPU worker 也會 append 進同一個檔)
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
log_path = os.path.join(LOG_DIR, f'xfr_debug_{time.strftime("%Y%m%d_%H%M%S")}.log') run_id = time.strftime("%Y%m%d_%H%M%S")
log_path = os.path.join(LOG_DIR, f'xfr_debug_{run_id}.log')
setup_tee(log_path) setup_tee(log_path)
print(f'Log file: {log_path}', flush=True) _setup_logging()
print(f'Command: {sys.executable} {" ".join(sys.argv)}', flush=True) logger.info(f'Log file: {log_path}')
print(f'Working directory: {os.getcwd()}', flush=True) logger.info(f'Command: {sys.executable} {" ".join(sys.argv)}')
logger.info(f'Working directory: {os.getcwd()}')
volumes = [d for d in sorted(os.listdir(standardized_dir)) volumes = [d for d in sorted(os.listdir(standardized_dir))
if os.path.isdir(os.path.join(standardized_dir, d))] if os.path.isdir(os.path.join(standardized_dir, d))]
vid_arg, level_arg = parse_args(sys.argv[1:])
if vid_arg is not None:
key = vid_arg.lower()
vols = [v for v in volumes
if v.lower() == key or v.rsplit('.', 1)[-1] == key]
if not vols:
sys.exit(f'Volume not found: {vid_arg}')
volumes = vols
volumes = volumes[:MAX_SUCCESSFUL_VOLUMES] volumes = volumes[:MAX_SUCCESSFUL_VOLUMES]
tasks = [(vid, level) for vid in volumes for level in LEVELS] levels = (level_arg,) if level_arg else LEVELS
print(f'Total {len(volumes)} volumes / {len(tasks)} (volume, level) tasks', flush=True) # 任務粒度 = (volume, level, side):同一 level 的 L/R 是兩個獨立任務,
# 可被不同 GPU 的 worker 領走並行執行;順序 L1 L -> L1 R -> L2 L -> L2 R -> ...
tasks = [(vid, level, s) for vid in volumes for level in levels for s in ('L', 'R')]
logger.info(f'Total {len(volumes)} volumes / {len(tasks)} (volume, level, side) tasks '
f'(levels: {", ".join(levels)})')
if vid_arg or level_arg:
logger.info(f'Filter: volume_id={vid_arg!r} level={level_arg!r}')
gpu_ids = list_gpu_ids() gpu_ids = list_gpu_ids()
if len(gpu_ids) <= 1: if len(gpu_ids) <= 1:
print(f'Only {len(gpu_ids)} GPU(s) available, running sequentially', flush=True) logger.info(f'Only {len(gpu_ids)} GPU(s) available, running sequentially')
_run_sequential(tasks) _run_sequential(tasks, run_id)
return return
print(f'Found {len(gpu_ids)} GPUs: {gpu_ids}, starting {len(gpu_ids)} workers (one per GPU)', flush=True) logger.info(f'Found {len(gpu_ids)} GPUs: {gpu_ids}, starting {len(gpu_ids)} workers (one per GPU)')
ctx = mp.get_context('spawn') ctx = mp.get_context('spawn')
task_queue = ctx.Queue() task_queue = ctx.Queue()
@ -323,7 +389,7 @@ def main():
for _ in gpu_ids: for _ in gpu_ids:
task_queue.put(None) # 每個 worker 一個結束哨兵 task_queue.put(None) # 每個 worker 一個結束哨兵
procs = [ctx.Process(target=gpu_worker, args=(g, log_path, task_queue, result_queue), name=f'cbt-gpu-{g}') procs = [ctx.Process(target=gpu_worker, args=(g, log_path, run_id, task_queue, result_queue), name=f'cbt-gpu-{g}')
for g in gpu_ids] for g in gpu_ids]
for p in procs: for p in procs:
p.start() p.start()
@ -333,7 +399,7 @@ def main():
finished = 0 finished = 0
while finished < len(gpu_ids): while finished < len(gpu_ids):
if not any(p.is_alive() for p in procs): if not any(p.is_alive() for p in procs):
print('Warning: a worker exited early; reaping remaining tasks...', flush=True) logger.warning('A worker exited early; reaping remaining tasks...')
break break
try: try:
msg = result_queue.get(timeout=5) msg = result_queue.get(timeout=5)
@ -357,27 +423,31 @@ def main():
p.join(timeout=60) p.join(timeout=60)
total_time = time.time() - start_time total_time = time.time() - start_time
ok = [r for r in results if r[4]] ok = [r for r in results if r[5]]
fail = [r for r in results if not r[4]] fail = [r for r in results if not r[5]]
per_volume = {} per_volume = {}
for _, _, vid, level, success, _ in results: per_volume_n = {}
for _, _, vid, level, side, success, _ in results:
per_volume.setdefault(vid, set()).add(success) per_volume.setdefault(vid, set()).add(success)
per_volume_n[vid] = per_volume_n.get(vid, 0) + 1
missing = len(tasks) - len(results) missing = len(tasks) - len(results)
# 一個 volume 算「成功」必須它的(level)全部執行過且全部成功 # 一個 volume 算「成功」必須它的所有 (level, side) 任務都執行過且全部成功
n_success_volumes = sum(1 for vid in volumes n_success_volumes = sum(
if per_volume.get(vid, set()) == {True}) 1 for vid in volumes
if per_volume.get(vid, set()) == {True}
and per_volume_n.get(vid, 0) == len(levels) * 2)
print('=' * 60) print('=' * 60)
print(f'Finished in {total_time / 60:.1f} min | ' logger.info(f'Finished in {total_time / 60:.1f} min | '
f'tasks {len(results)}/{len(tasks)} (ok {len(ok)} / failed {len(fail)} / not-run {missing})') f'tasks {len(results)}/{len(tasks)} (ok {len(ok)} / failed {len(fail)} / not-run {missing})')
if missing: if missing:
print(f'Warning: {missing} task(s) were never executed (worker crash?)') logger.warning(f'{missing} task(s) were never executed (worker crash?)')
print(f'Successful volumes (all levels OK): {n_success_volumes}/{len(volumes)}') logger.info(f'Successful volumes (all levels OK): {n_success_volumes}/{len(volumes)}')
if fail: if fail:
print('Failed tasks:') logger.info('Failed tasks:')
for _, g, vid, level, _, err in fail: for _, g, vid, level, side, _, err in fail:
print(f' [GPU {g}] {vid} {level}: {err}') logger.error(f'[GPU {g}] {vid} {level} {side}: {err}')
if __name__ == '__main__': if __name__ == '__main__':

97
xfr_plot_level.py Normal file
View file

@ -0,0 +1,97 @@
#!/home/xfr/.conda/envs/cbt/bin/python
"""
為每個 (volume, level) 的骨頭遮罩{level}_binary_sdf.nii.gz缺則 _binary繪製
X-ray 四視角圖不畫螺絲存到 Output/{date}/{volume}/
輸出檔名{volume} {level}_CBT.png
1.3.6.1.4.1.9328.50.4.0005 L1_CBT.png
Usage:
python xfr_plot_level.py # 全部 volume 的 L1~L5
python xfr_plot_level.py 0005 # 該 volume 的 L1~L5
python xfr_plot_level.py 0005 L1 # 單一 (volume, level)
python xfr_plot_level.py --dir <standardized_dir> --output <output_base>
"""
import argparse
import os
import sys
from visualization.res_bone_figure import render_bone_figure
standardized_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr/'
output_base = '/mnt/1248/open2/cyrou/Output'
LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5')
USAGE = 'Usage: python xfr_plot_level.py [volume_id] [level] [--dir D] [--output O]'
def parse_args(argv):
parser = argparse.ArgumentParser(description='繪製各 lumbar level 的骨頭 X-ray 圖(不畫螺絲)')
parser.add_argument('volume_id', nargs='?', default=None,
help='volume ID完整 UID 或末段,如 0005省略全部')
parser.add_argument('level', nargs='?', default=None,
help=f'level{" / ".join(LEVELS)});省略=全部')
parser.add_argument('--dir', default=standardized_dir,
help=f'standardized 資料夾(預設 {standardized_dir}')
parser.add_argument('--output', default=output_base,
help=f'輸出根目錄(預設 {output_base}')
return parser.parse_args(argv)
def main():
args = parse_args(sys.argv[1:])
if args.level is not None:
level_key = args.level.upper()
if level_key not in LEVELS:
print(f'Invalid level: {args.level} (choose from {"/".join(LEVELS)})')
sys.exit(1)
if args.level is not None and args.volume_id is None:
print(f'{USAGE}\nlevel requires volume_id')
sys.exit(1)
volumes = [d for d in sorted(os.listdir(args.dir))
if os.path.isdir(os.path.join(args.dir, d))]
if args.volume_id is not None:
key = args.volume_id.lower()
vols = [v for v in volumes
if v.lower() == key or v.rsplit('.', 1)[-1] == key]
if not vols:
print(f'Volume not found: {args.volume_id}')
sys.exit(1)
volumes = vols
levels = (args.level.upper(),) if args.level else LEVELS
tasks = [(vid, lvl) for vid in volumes for lvl in levels]
print(f'{len(volumes)} volume(s) x {len(levels)} level(s) = {len(tasks)} figure(s)',
flush=True)
ok, skip = [], []
for i, (vid, lvl) in enumerate(tasks, 1):
vol_dir = os.path.join(args.dir, vid)
# _binary.nii.gz 現為原解析度0.5mm 用 _binary_sdfSDF 平滑遮罩)
sdf_path = os.path.join(vol_dir, f'{lvl}_binary_sdf.nii.gz')
binary_path = sdf_path if os.path.exists(sdf_path) \
else os.path.join(vol_dir, f'{lvl}_binary.nii.gz')
cortical_path = os.path.join(vol_dir, f'{lvl}_cortical.nii.gz')
path = render_bone_figure(vid, lvl, binary_path, cortical_path,
base_folder=args.output)
if path is None:
skip.append((vid, lvl))
print(f'[{i}/{len(tasks)}] {vid} {lvl}: skipped', flush=True)
else:
ok.append((vid, lvl))
print(f'[{i}/{len(tasks)}] {vid} {lvl}: saved {path}', flush=True)
print('=' * 60)
print(f'Done. saved={len(ok)} skipped={len(skip)}')
if skip:
print('Skipped (missing/empty mask):')
for vid, lvl in skip:
print(f' {vid} {lvl}')
if __name__ == '__main__':
main()

View file

@ -1,10 +1,32 @@
#!/home/xfr/.conda/envs/cbt/bin/python
import argparse
import json
import logging
import os import os
import sys
import time
import numpy as np
import SimpleITK as sitk
from tinydb import TinyDB, Query
from imaging.preprocessing import process_dataset from imaging.preprocessing import process_dataset
from config.constant import LABEL_MAP
from imaging.orientation import (best_symmetry_plane, best_upper_endplate_plane,
segment_spinous_process, segment_vertebral_body,
smooth_mask_sdf)
from visualization.res_bone_figure import (render_bone_figure,
compute_normalizing_rotation,
rotate_volume_to, rotated_grid,
rotated_sitk_image_at,
_rotate_plane_params,
_shift_plane_params)
data_root = '/mnt/1220/Public/dataset/Spine/CTSpine1K/data/' data_root = '/mnt/1220/Public/dataset/Spine/CTSpine1K/data/'
label_root = '/mnt/1220/Public/dataset/Spine/CTSpine1K/label/' label_root = '/mnt/1220/Public/dataset/Spine/CTSpine1K/label/'
output_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-2/' output_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-2/'
output_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-3/'
label_map = { label_map = {
'colon': 'conlon', 'colon': 'conlon',
@ -13,12 +35,407 @@ label_map = {
'liver': 'Liver', 'liver': 'Liver',
} }
LUMBAR_LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5', 'L6')
MAX_Z_SPACING_MM = 4.0 # z spacing (mm) 大於此值者跳過整支 pipeline
MIN_LUMBAR_LEVELS = 2 # 符合 lumbar 的 label 少於此值者跳過整支 pipeline
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
METADATA_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'xfr_image_metadata.json')
logger = logging.getLogger('xfr_preprocess')
def _upsert_by_name(table, name, meta):
"""'name' 欄位為鍵 upsertTinyDB 4.9 insert 不支援自訂 doc_id"""
q = Query().name == name
entry = dict(table.get(q) or {})
entry.update(meta)
entry['name'] = name
if table.get(q) is None:
table.insert(entry)
else:
table.update(entry, q)
def _migrate_legacy_metadata(path):
"""舊版純 JSON 檔({name: {...}}、無 TinyDB 的 _default 結構):
改名成 .legacy-<timestamp> 備份內容匯入新 TinyDB"""
if not os.path.exists(path):
return
try:
with open(path) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return
legacy = {k: v for k, v in data.items()
if isinstance(v, dict)} if isinstance(data, dict) else {}
if not (isinstance(data, dict) and '_default' not in data and legacy):
return
bak = f'{path}.legacy-{time.strftime("%Y%m%d_%H%M%S")}'
os.replace(path, bak)
print(f'Metadata db {path} is old pure-JSON format; renamed to {bak} '
f'and migrating {len(legacy)} entrie(s)')
t = TinyDB(path).table('images')
for k, v in legacy.items():
_upsert_by_name(t, k, dict(v))
class ImageMetadataDB:
"""影像 metadata 快取TinyDBtable 'images'
以影像檔名前綴= 輸出資料夾名 1.3.6.1.4.1.9328.50.4.0001
liver_100volume-covid19-A-0011_ct key存於文件 'name' 欄位
記錄 {'spacing': [x, y, z], 'labels': [label id]}
process_single_image 做跳過判定z spacing / lumbar 層數
命中時不必讀影像 / label TinyDB 每次操作自動落檔
中途重跑可繼承
"""
def __init__(self, path=METADATA_DB):
self.path = path
_migrate_legacy_metadata(path)
self._table = TinyDB(path).table('images')
def get(self, name):
return self._table.get(Query().name == name)
def put(self, name, meta):
_upsert_by_name(self._table, name, meta)
def __len__(self):
return len(self._table)
class _Tee:
"""同時輸出到 console 與 log 檔,逐行寫入。"""
def __init__(self, console, log_fh):
self.console = console
self.log_fh = log_fh
self.buf = ''
def _emit(self, line):
self.console.write(line + '\n')
self.log_fh.write(line + '\n')
def write(self, data):
if not data:
return
self.buf += data
while True:
idx_n = self.buf.find('\n')
idx_r = self.buf.find('\r')
candidates = [i for i in (idx_n, idx_r) if i != -1]
if not candidates:
break
idx = min(candidates)
self._emit(self.buf[:idx])
self.buf = self.buf[idx + 1:]
def flush(self):
if self.buf:
self._emit(self.buf)
self.buf = ''
self.console.flush()
self.log_fh.flush()
def isatty(self):
return False
def setup_tee(log_path):
"""把這個流程的 stdout/stderr 同時寫到 log_pathappend、line-buffered"""
log_fh = open(log_path, 'a', buffering=1)
sys.stdout = _Tee(sys.stdout, log_fh)
sys.stderr = _Tee(sys.stderr, log_fh)
def _cortical_from_roi(roi_arr, bin_arr):
"""皮質遮罩(與舊未旋轉 _cortical.nii.gz 同定義):
門檻 = 骨頭 maskbin_arr CT median HU
cortical = mask 內且 門檻 <= HU <= 10000
roi_arr / bin_arr 須同 grid(z, y, x)回傳 uint8 0/1
輸入缺失或 mask 為空時回傳 None"""
if roi_arr is None or bin_arr is None:
return None
inside = bin_arr > 0
if int(inside.sum()) == 0:
return None
threshold = float(np.median(roi_arr[inside]))
return (inside & (roi_arr >= threshold) & (roi_arr <= 10000)).astype(np.uint8)
def _write_rotated_level(vol_dir, level, smd_path, mask_path, roi_path):
"""對單一 level計算對齊旋轉鏡稱面 -> +X、上終板 normal y=0
存到 <vol_dir>/rotated/先旋轉 _binary_nn再以它的非零 bbox±4
體素作為所有旋轉輸出的裁切 grid尺寸不求同未旋轉版整顆骨頭
保留origin 隨裁切平移
- _smd_resampled.nii.gz 三线性旋轉後 SMD內負/外正
- _binary_sdf.nii.gz 旋轉 SMD 0.5 閾值與未旋轉版一致
- _binary_nn.nii.gz NN 旋轉後 0/1 遮罩對比用裁切基準
- _roi.nii.gz
- _cortical.nii.gz 旋轉 CT 以骨頭 mask median HU 為門檻
取代舊的未旋轉 _cortical定義相同
再畫rotated平面圖並在旋轉體上做 VBODY / 棘突分割存 label map
1=VBODY2=棘突3=other bone0=background
平面參數經 R 剛性旋轉並換元到輸出 grid 的局部座標
旋轉來源 + 幾何模板patient 未旋轉的 0.5mm _smd_resampled.nii.gz
float裁物件 bboxSMD < 0.5 = 內部 _binary_sdf 同幾何
缺失或格式不符時回退 _binary_nn.nii.gz0.5mm 最近邻 0/1
此情形只產出 _binary_nn+ roi / cortical / / label
mask_path未旋轉 0.5mm 0/1 遮罩 _binary_sdf只供畫圖
render_bone_figure uint8 讀取float 檔會被讀成全 0
缺失時自動回退 _binary_nn
"""
volume_id = os.path.basename(vol_dir)
template = None
smd_arr = None
if smd_path is not None and os.path.exists(smd_path):
sa = sitk.GetArrayFromImage(sitk.ReadImage(smd_path)).astype(np.float32)
if sa.size > 0 and np.isfinite(sa).all() and sa.min() < 0.0 and sa.max() > 0.0:
template = sitk.ReadImage(smd_path) # 0.5mm 幾何模板
smd_arr = sa
else:
logger.warning(f'[rotated] {volume_id} {level}: _smd_resampled 不可用 '
f'(value),改用 _binary_nn')
if template is None:
nn_in_path = os.path.join(vol_dir, f'{level}_binary_nn.nii.gz')
if not os.path.exists(nn_in_path):
logger.warning(f'[rotated] {volume_id} {level}: _smd_resampled / _binary_nn 皆不可用, skip')
return
template = sitk.ReadImage(nn_in_path)
# 平面估計輸入SMD 時取 0.5 閾值內部,與 _binary_sdf 同定義
if smd_arr is not None:
bin_arr = (smd_arr < 0.5).astype(np.uint8)
else:
bin_arr = (sitk.GetArrayFromImage(template) > 0).astype(np.uint8)
if int(bin_arr.sum()) == 0:
logger.warning(f'[rotated] {volume_id} {level}: empty binary, skip')
return
# 圖用遮罩須與模板同幾何SDF 與 SMD 皆裁 bbox2NN fallback 只有
# NN 遮罩一致0.5mm 0/1float 檔會被 uint8 讀取成全 0
nn_in_path = os.path.join(vol_dir, f'{level}_binary_nn.nii.gz')
if smd_arr is None:
mask_path = nn_in_path if os.path.exists(nn_in_path) else mask_path
if mask_path is None or not os.path.exists(mask_path):
mask_path = nn_in_path if os.path.exists(nn_in_path) else None
sym = best_symmetry_plane(bin_arr)
symp = best_upper_endplate_plane(bin_arr)
R, c_xyz = compute_normalizing_rotation(bin_arr, sym, symp)
# 輸出 grid未旋轉緊密 bbox 的 8 角點旋轉後的最小包圍盒 + margin——
# 尺寸不受未旋轉版約束,保證旋轉後整顆骨頭保留(同尺寸旋轉切角落)
start, size = rotated_grid(bin_arr.shape, R, c_xyz, margin=4)
rotated_dir = os.path.join(vol_dir, 'rotated')
os.makedirs(rotated_dir, exist_ok=True)
# 先旋轉 _binary_nnorder=0未旋轉 _binary_nn 先重採樣到模板 grid
# 再旋轉),以其非零 bbox外扩 4 體素)定所有旋轉輸出的裁切 grid
# fstart/fsize模板 index 系NN 缺失 / 為空時回退緊密包圍盒
rot_nn = None
fstart = (int(start[0]), int(start[1]), int(start[2]))
fsize = (int(size[0]), int(size[1]), int(size[2]))
if os.path.exists(nn_in_path):
nn_img = sitk.ReadImage(nn_in_path)
nn_arr = (sitk.GetArrayFromImage(
sitk.Resample(nn_img, template, interpolator=sitk.sitkNearestNeighbor,
defaultPixelValue=0)) > 0).astype(np.uint8)
rot_nn_full = (rotate_volume_to(nn_arr, R, c_xyz, start, size,
order=0, cval=0.0) > 0.5).astype(np.uint8)
p_nn = os.path.join(rotated_dir, f'{level}_binary_nn.nii.gz')
if int(rot_nn_full.sum()) > 0:
zz, yy, xx = np.where(rot_nn_full > 0)
m = 4
z0 = max(0, int(zz.min()) - m)
z1 = min(rot_nn_full.shape[0] - 1, int(zz.max()) + m)
y0 = max(0, int(yy.min()) - m)
y1 = min(rot_nn_full.shape[1] - 1, int(yy.max()) + m)
x0 = max(0, int(xx.min()) - m)
x1 = min(rot_nn_full.shape[2] - 1, int(xx.max()) + m)
fstart = (fstart[0] + x0, fstart[1] + y0, fstart[2] + z0)
fsize = (x1 - x0 + 1, y1 - y0 + 1, z1 - z0 + 1)
rot_nn = rot_nn_full[z0:z1 + 1, y0:y1 + 1, x0:x1 + 1]
else:
rot_nn = rot_nn_full
sitk.WriteImage(rotated_sitk_image_at(template, rot_nn, fstart), p_nn)
logger.info(f'[rotated] saved {p_nn} (grid={list(fsize)}, start={fstart})')
# 旋轉後 SMD未旋轉 _smd_resampled 的 SMD 場經三线性旋轉得以保留
# float32 來源map_coordinates 輸出維持浮點,<0.5 即為真閾值);
# 體外填充值取正 SMD背景側避免裁切邊界出現假的閾值穿越
rot_sdf_bin = None
if smd_arr is not None:
rot_smd = rotate_volume_to(smd_arr, R, c_xyz, fstart, fsize,
order=1, cval=float(smd_arr.max()))
p_smd = os.path.join(rotated_dir, f'{level}_smd_resampled.nii.gz')
sitk.WriteImage(rotated_sitk_image_at(template, rot_smd.astype(np.float32),
fstart), p_smd)
logger.info(f'[rotated] saved {p_smd}')
# _binary_sdf.nii.gz旋轉 SMD 於 0.5 閾值(與未旋轉版一致)
rot_sdf_bin = (rot_smd < 0.5).astype(np.uint8)
p_sdf = os.path.join(rotated_dir, f'{level}_binary_sdf.nii.gz')
sitk.WriteImage(rotated_sitk_image_at(template, rot_sdf_bin, fstart), p_sdf)
logger.info(f'[rotated] saved {p_sdf}')
# 旋轉後的 roi三线性先重採樣到模板 grid再旋轉到裁切 grid
roi_arr = None
rot_roi_arr = None
if roi_path is not None and os.path.exists(roi_path):
roi_img = sitk.ReadImage(roi_path)
roi_arr = sitk.GetArrayFromImage(
sitk.Resample(roi_img, template, defaultPixelValue=0.0))
rot_roi = rotate_volume_to(roi_arr, R, c_xyz, fstart, fsize,
order=1, cval=0.0)
p_roi = os.path.join(rotated_dir, f'{level}_roi.nii.gz')
sitk.WriteImage(rotated_sitk_image_at(template, rot_roi, fstart), p_roi)
logger.info(f'[rotated] saved {p_roi}')
rot_roi_arr = rot_roi
# 分割 / cortical 輸入SMD 時直接用旋轉 _binary_sdf0.5 閾值、
# 次體素平滑NN fallback 時以 smooth_mask_sdf 記憶體內平滑(不存檔)
if rot_sdf_bin is not None:
rot_bin_seg = rot_sdf_bin
else:
rot_bin_seg = smooth_mask_sdf(rot_nn, sigma=1.5).astype(np.uint8)
# 旋轉後的 cortical旋轉 CT 以骨頭 mask 內 median HU 為門檻
#(取代舊的未旋轉 _cortical.nii.gz定義相同
rot_cort = _cortical_from_roi(rot_roi_arr, rot_bin_seg)
if rot_cort is not None:
p_cort = os.path.join(rotated_dir, f'{level}_cortical.nii.gz')
sitk.WriteImage(rotated_sitk_image_at(template, rot_cort, fstart), p_cort)
logger.info(f'[rotated] saved {p_cort}')
else:
logger.warning(f'[rotated] {volume_id} {level}: 無旋轉 CT / mask跳過 _cortical')
# 用旋轉後的平面畫圖rotated 版 planes皮質著色由未旋轉 CT + mask
# 現算(未旋轉 _cortical 不再存檔)
p_fig = os.path.join(rotated_dir, f'{level}_planes.png')
if mask_path is not None:
fig_cortical = _cortical_from_roi(roi_arr, bin_arr)
fig = render_bone_figure(volume_id, level, mask_path, fig_cortical,
planes_only=True, rotation=(R, c_xyz), output_path=p_fig)
if fig is not None:
logger.info(f'[rotated] saved {fig}')
else:
logger.warning(f'[rotated] {volume_id} {level}: 無可用 0/1 遮罩,跳過平面圖')
# 旋轉體上的 VBODY / 棘突分割:平面參數隨 R 剛性旋轉rotated 體與
# 原始體只差一個剛性變換,平面隨動即可,不需重新偵測),
# label map 存同資料夾1 = VBODY椎體2 = 棘突spinous process
# 3 = other bone其餘骨頭0 = background。
# 換元到輸出 grid 的局部座標origin 在模板 index fstart 處)
sym_rot = _shift_plane_params(_rotate_plane_params(sym, R, c_xyz), fstart)
symp_rot = (_shift_plane_params(_rotate_plane_params(symp, R, c_xyz), fstart)
if symp is not None else None)
sp_mask, sp_th, sp_info = segment_spinous_process(rot_bin_seg, sym_rot)
vb_mask, vb_th, vb_info = segment_vertebral_body(rot_bin_seg, sym_rot, symp_rot,
sp_th, sp_info['mode'])
label_arr = np.zeros(rot_bin_seg.shape, dtype=np.uint8)
label_arr[rot_bin_seg > 0] = 3 # other bone
if vb_mask is not None:
label_arr[vb_mask] = 1 # VBODY
if sp_mask is not None:
label_arr[sp_mask] = 2 # spinous process
p_lbl = os.path.join(rotated_dir, f'{level}_label.nii.gz')
sitk.WriteImage(rotated_sitk_image_at(template, label_arr, fstart), p_lbl)
logger.info(f'[rotated] saved {p_lbl} '
f'(vbody={int((label_arr == 1).sum())}, spinous={int((label_arr == 2).sum())}, '
f'other={int((label_arr == 3).sum())}, '
f'sp_mode={sp_info["mode"]}, vb_mode={vb_info["mode"]})')
def make_lumbar_post_process():
"""每個 volume 處理完後,對其 lumbar level
1) 骨頭 + 方向平面不畫螺絲不做棘突 / 椎體分割-> <volume_dir>/lumbar/
2) 計算對齊旋轉存旋轉後的 smd_resampled / binary_sdf / binary_nn / roi
+ cortical + 旋轉平面圖 + label map -> <volume_dir>/rotated/
post_process process_dataset 呼叫(volume_dir, processed_labels)
processed_labels 為該 volume 實際存在的 label idint對照 LABEL_MAP
"""
def _post_process(vol_dir, processed_labels):
volume_id = os.path.basename(vol_dir)
lumbar_dir = os.path.join(vol_dir, 'lumbar')
for n in processed_labels:
level = LABEL_MAP.get(int(n))
if level not in LUMBAR_LEVELS:
continue
smd_res_path = os.path.join(vol_dir, f'{level}_smd_resampled.nii.gz')
nn_path = os.path.join(vol_dir, f'{level}_binary_nn.nii.gz')
if not os.path.exists(smd_res_path) and not os.path.exists(nn_path):
continue
# 畫圖用 0/1 遮罩:優 _binary_sdf0.5mm 平滑),缺則 _binary_nn
sdf_path = os.path.join(vol_dir, f'{level}_binary_sdf.nii.gz')
mask_path = sdf_path if os.path.exists(sdf_path) else nn_path
roi_path = os.path.join(vol_dir, f'{level}_roi.nii.gz')
# 1) 原始未旋轉planes 圖;皮質著色由未旋轉 CT + mask 現算
#(未旋轉 _cortical 不再存檔)
output_path = os.path.join(lumbar_dir, f'{level}_planes.png')
if os.path.exists(mask_path):
fig_cortical = None
if os.path.exists(roi_path):
mask_img = sitk.ReadImage(mask_path)
roi_img = sitk.ReadImage(roi_path)
roi_arr = sitk.GetArrayFromImage(
sitk.Resample(roi_img, mask_img, defaultPixelValue=0.0))
fig_cortical = _cortical_from_roi(
roi_arr, sitk.GetArrayFromImage(mask_img))
path = render_bone_figure(volume_id, level, mask_path, fig_cortical,
planes_only=True, output_path=output_path)
if path is not None:
logger.info(f'[lumbar] saved {path}')
# 2) 旋轉對齊rotated/ 的 smd_resampled + binary_sdf + binary_nn
# + roi + cortical + planes 圖 + label
_write_rotated_level(vol_dir, level, smd_res_path, mask_path, roi_path)
return _post_process
def main(): def main():
parser = argparse.ArgumentParser(description='Preprocess CT spine dataset.')
parser.add_argument('--max-images', type=int, default=None, dest='max_images',
help='Process at most this number of images per dataset (default: all).')
args = parser.parse_args()
# log 檔console 與檔案同時輸出)
os.makedirs(LOG_DIR, exist_ok=True)
log_path = os.path.join(LOG_DIR, f'xfr_preprocess_{time.strftime("%Y%m%d_%H%M%S")}.log')
setup_tee(log_path)
# basicConfig 在 setup_tee 之後,讓 handler 寫入 Tee同時進 console 與 log 檔)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger.info(f'Log file: {log_path}')
logger.info(f'Command: {sys.executable} {" ".join(sys.argv)}')
logger.info(f'Working directory: {os.getcwd()}')
# metadata db跳過判定z spacing / lumbar 層數)命中時免讀影像 / label 檔
metadata_db = ImageMetadataDB()
logger.info(f'Metadata db: {metadata_db.path} ({len(metadata_db)} entries)')
post_process = make_lumbar_post_process()
for key, value in label_map.items(): for key, value in label_map.items():
data_dir = os.path.join(data_root, key) data_dir = os.path.join(data_root, key)
label_dir = os.path.join(label_root, value) label_dir = os.path.join(label_root, value)
process_dataset(data_dir, label_dir, output_dir) process_dataset(data_dir, label_dir, output_dir, max_images=args.max_images,
post_process=post_process, max_z_spacing=MAX_Z_SPACING_MM,
allowed_levels=LUMBAR_LEVELS, min_levels=MIN_LUMBAR_LEVELS,
metadata_cache=metadata_db)
if __name__ == '__main__': if __name__ == '__main__':
main() main()