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
@ -43,8 +63,9 @@ def refine_lateral_longer(
if cyl.sum().item() == 0: if cyl.sum().item() == 0:
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 的 y(voxel index)。
endplate_plane 為 {'plane': (a, b, c, d), ...}(法線朝上,c > 0)時,
入口點 (x, y, z) 落在終板面之上的柱也設 OUTSIDE_VALUE,
避免從終板上方的柱選入射點。
vbody_mask((z, y, x) 0/1,VBODY 椎體 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
@ -104,7 +138,35 @@ 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,
) )
azi = azimuth_rotation(image2_path) if not CBT:
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False) azi = azimuth_rotation(image2_path)
alt = res['superior']['tilt_angle_deg'] res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
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>0),az/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_process,sym)
# tau_y = atan2(e_ny, e_nz):終板 AP 面傾斜;負 = 面向 +y(終板側)升高, # - 入口面終板上方剪除(get_first_nonzero_y,endplate)
# 軌跡需爬得更陡 → altitude 中心減小(舊式 65-alt 的 3D 版)。 # - VBODY 椎體分割(segment_vertebral_body,sym + endplate)
# tau_x = atan2(e_nx, e_nz):終板 LR 面傾斜;正 = 面向小 x(L 側)升高, # - 對齊健全性檢查:基準系下 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():
# 上終板面與椎體都在「完整 mask(SP 移除前)」上計算:
# - 終板面由前側頂面擬合,SP 移除不改變平面;
# - 椎體與 res_plt_2_torch 的 gold 顯示完全同 input / 同參數,
# 確保顯示出來的椎體就是 loss 裡 VBODY 獎勵的區域。
# 棘突缺如(laminectomy,mode='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,220 +468,200 @@ 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 是兩次獨立 PSO(bounds 不同、目標函數共用),互不依賴:
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)由「較晚完成」的一側在
# ieqcons=[constraint_y], # 下方「單側收尾」段觸發。
swarmsize=swarm_size, def _side_bounds(s_i):
omega = omega, if s_i == 'L':
maxiter=max_iter, debug=debug) return lb_l, ub_l, azimuth_bounds_l, x_bounds_left
return lb_r, ub_r, azimuth_bounds_r, x_bounds_right
z, x, azimuth, altitude, diameter, length = position_l def _run_one_side(s_i):
az_pso, x_pso, L_pso = azimuth, x, length lb_s, ub_s, az_bounds_s, x_bounds_s = _side_bounds(s_i)
y = y_indices[round(z), round(x)] 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],
swarmsize=swarm_size,
omega=omega,
maxiter=max_iter, debug=debug)
z, x, azimuth, altitude, diameter, length, adopted_l, ref_l = refine_lateral_longer( # 如果需要 retry(loss > 0):重跑 PSO 取較好者
z, x, azimuth, altitude, diameter, length, # (原 L/R 各一份註解版 retry,此處合併為一式)
"L", azimuth_bounds_l, x_bounds_left, y_indices, # max_retries = 0
image_shape, spacing, device, grid, # retries = 0
cortical_tensor, spine_tensor, # while loss > 0 and retries < max_retries:
) # position, loss = pso(objective_fn, lb_s, ub_s,
y = y_indices[round(z), round(x)] # swarmsize=swarm_size, maxiter=max_iter)
if adopted_l: # retries += 1
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 z, x, azimuth, altitude, diameter, length = position
az_pso, x_pso, L_pso = azimuth, x, length
y = y_indices[round(z), round(x)]
overlap_l, diameter_l, length_l = eval_overlap_from_position( z, x, azimuth, altitude, diameter, length, adopted, ref = refine_lateral_longer(
position_l, "L", optimize_size, spine_tensor, image_shape, spacing z, x, azimuth, altitude, diameter, length,
) s_i, az_bounds_s, x_bounds_s, y_indices,
print(f"[LEFT] overlap: {overlap_l*100:.1f}%") image_shape, spacing, device, grid,
cortical_tensor, spine_tensor,
vbody_tensor,
)
y = y_indices[round(z), round(x)]
if adopted:
loss = ref['loss']
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['in_bone']*100:.1f}%")
else:
print(f"[{tag}] lateral-refine: no improvement")
if optimize_size: position = z, y, x, azimuth, altitude, diameter, length
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 overlap_s, d_snap, L_snap = eval_overlap_from_position(
best_overlap_l = overlap_l # 新增 position, s_i, optimize_size, spine_tensor, image_shape, spacing
)
# max_retries = 0 print(f"[{tag}] overlap: {overlap_s*100:.1f}%")
# retries = 0
# 左側 retry:loss 要 <=0 且 overlap >= 0.5 才算過關 if optimize_size:
# while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < max_retries: print(f"[{tag}] Position: {position[:5]}")
# position_l, loss_l = pso(objective_fn, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter) print(f"[{tag}] Diameter: {d_snap} mm (raw: {position[5]:.2f})")
# overlap_l, diameter_l, length_l = eval_overlap_from_position( print(f"[{tag}] Length: {L_snap} mm (raw: {position[6]:.2f})")
# position_l, "L", optimize_size, spine_tensor, image_shape, spacing print(f"[{tag}] Loss: {loss}\n")
# ) best_pos = list(position[:5]) + [d_snap, L_snap]
else:
print(f"[{tag}] Position: {position}")
best_pos = position
return best_pos, loss, overlap_s
# 只要找到更好的 loss(或你想用 loss+overlap 綜合排序也行)就更新 best sides = ('L', 'R') if side == 'both' else (side,)
# 安全版本:優先選「合格解」;沒有合格解時才用 loss 最小的當備案 side_time = {}
# candidate_pos = (list(position_l[:5]) + [diameter_l, length_l]) if optimize_size else position_l for s_i in sides:
t0 = time.time()
pos_s, loss_s, _ = _run_one_side(s_i)
side_time[s_i] = time.time() - t0
if s_i == 'L':
best_position_l, best_loss_l = pos_s, loss_s
else:
best_position_r, best_loss_r = pos_s, loss_s
# 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
# Right side optimization
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
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,
"R", azimuth_bounds_r, x_bounds_right, y_indices,
image_shape, spacing, device, grid,
cortical_tensor, spine_tensor,
)
y = y_indices[round(z), round(x)]
if adopted_r:
loss_r = ref_r['loss']
print(f"[RIGHT] 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}%")
else:
print("[RIGHT] lateral-refine: no improvement")
position_r = z, y, x, azimuth, altitude, diameter, length
overlap_r, diameter_r, length_r = eval_overlap_from_position(
position_r, "R", optimize_size, spine_tensor, image_shape, spacing
)
print(f"[RIGHT] overlap: {overlap_r*100:.1f}%")
if optimize_size:
# diameter_r, length_r = snap_to_discrete_values(position_r[5], position_r[6])
diameter_r, length_r = snap_to_discrete_values_xfr(position_r[5], position_r[6])
print(f"[RIGHT] Position: {position_r[:5]}")
print(f"[RIGHT] Diameter: {diameter_r} mm (raw: {position_r[5]:.2f})")
print(f"[RIGHT] Length: {length_r} mm (raw: {position_r[6]:.2f})")
print(f"[RIGHT] Loss: {loss_r}\n")
best_position_r = list(position_r[:5]) + [diameter_r, length_r]
else:
print(f"[RIGHT] Position: {position_r}")
print(f"[RIGHT] Loss: {loss_r}\n")
best_position_r = position_r
best_loss_r = loss_r
best_overlap_r = overlap_r
# 如果需要 retry(loss > 0)
# max_retries = 10
# retries = 0
# while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < max_retries:
# position_r, loss_r = pso(objective_fn, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter)
# overlap_r, diameter_r, length_r = eval_overlap_from_position(
# 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
res_plt_2_torch( def _plot_combined(d_l, l_l, d_r, l_r, pos_l, pos_r, total_t):
spine_tensor, res_plt_2_torch(
cortical_tensor, spine_tensor,
image_shape, cortical_tensor,
image2_path, image_shape,
folder, image2_path,
label_str, folder,
final_diameter_l, label_str,
final_length_l, d_l,
final_diameter_r, l_l,
final_length_r, d_r,
best_position_l, l_r,
best_position_r, pos_l,
swarm_size, pos_r,
max_iter, swarm_size,
total_time, max_iter,
spacing, total_t,
CBT, spacing,
device, CBT,
grid) device,
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 lock(O_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/1,None = 不計 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 擴展到 cortical∪VBODY;cortical 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 : 中線帶半寬 w(voxel)
deficit : 後側中線缺如量(voxel,0.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_runs,expand_cap):帶由鏡稱面定義,
棘突楔若略偏中線,側緣薄條會留在帶外成為 other bone;每條 (y, z)
線把棘突 run 向左右各補至多 expand_cap 個 bone voxel。
先做後側中線缺如判定(diagnose_spinous_process,laminectomy/棘突切除);
缺如、或谷底後側叢只剩 <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 = None(info['mode'] 說明原因)。 資料過少或棘突缺如時 sp_mask = None(info['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 : 平滑強度(voxel)。1.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 interpolation(SBI)平滑:以 0..1 體積的 0.5 等值面為
物件形狀(shape),計算有號距離場(SDF,內正外負),在距離域做高斯
插值(interpolation),再於 0 重新閾值,得到圓滑的 smooth mask。
與 smooth_mask_sdf 的差異:邊界層(0 < v < 1)內三线性場值滿足
v ≈ 0.5 + d(d = 以 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) 最後 fallback:AP 分佈 55 百分位(可能切進椎體內,會打 WARNING)。
椎體 = AP < 切點(切點之前側)且終板下側的 bone。
回傳 (vb_mask (z,y,x) bool, ap_thresh, info dict);
資料不足或上終板面缺位時 vb_mask = None(info['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 最上 z,RANSAC 擬合矢狀 (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+s²+c²)、
d = (i+k)/√(1+s²+c²)、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 = d0,n0 = (−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)
@ -34,14 +110,7 @@ def process_single_image(image_path, label_path, output_dir_base=None):
# Assume to have some sitk image (itk_image) and label (itk_label) # Assume to have some sitk image (itk_image) and label (itk_label)
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)
standardize_affine(path, output_dir) 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)
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() - CT(is_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)。
label(is_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 值 = 影像最小 HU(statistics 濾波器 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-連通的最大连通區域 + 其 bbox(RelabelComponent 依大小排序,最大者=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 個 voxel,clamp 到影像邊界)。
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
binary_linear_path = binary_nn_path = None
if original_label is not None:
bin_orig = sitk.BinaryThreshold(original_label, n, n, 1, 0)
cc_orig = _largest_cc_bbox(bin_orig)
if cc_orig is None:
return None
largest_orig, bbox_orig = cc_orig
# if not lssif.HasLabel(n): # _binary.nii.gz:原解析度【未插值】遮罩(最大连通區域、裁到物件 bbox,
# raise RuntimeError(f"Label {n} not found") # 不重取樣、不插值 —— 原始 label 的忠實二值版本)
binary_path = os.path.join(output_base, f"{label_name}_binary.nii.gz")
sitk.WriteImage(_bbox_roi(largest_orig, bbox_orig), binary_path)
# bbox2 = lssif.GetBoundingBox(n) # _smd.nii.gz:SignedMaurerDistanceMap(ITK 慣例:物件內負 / 外正;
# 距離以原始 index(pixel)單位、不隨各向异性 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)
# 1. 提取標籤 n 的二值遮罩 (將標籤 n 設為 1,其餘為 0) # SMD 體積【线性插值】重取樣到 0.5mm(reference = 0.5mm CT,與
binary_mask = sitk.BinaryThreshold(resampled_sitk_lbl, n, n, 1, 0) # resampled_sitk_img 同 grid,之後可直接用 0.5mm bbox 裁切)。
# SMD 在每個 input voxel 內分段線性,线性重取樣近似精確、無漣波,
# 2. 獲取所有連通區域 # 各等值面(物件邊界等)不變。填充值 = 裁切角落(背景側,正值);
# 連通區域濾波器會將 binary_mask 中的不同物體標記為 1, 2, 3... # 若為負(物件貼影像邊界的病態情況)用 0。
cc_image = sitk.ConnectedComponent(binary_mask, True) #fullyConnected corner = float(sitk.GetArrayViewFromImage(smd_margined).flat[0])
rs = sitk.ResampleImageFilter()
# 3. 根據區域大小(像素/體積)重新標記 rs.SetReferenceImage(resampled_sitk_img)
# RelabelComponent 會按大小排序,最大的物體標籤會被設為 1 rs.SetInterpolator(sitk.sitkLinear)
relabeled_cc = sitk.RelabelComponent(cc_image, sortByObjectSize=True) rs.SetDefaultPixelValue(corner if corner > 0 else 0.0)
largest_mask = relabeled_cc == 1 smd_res_full = rs.Execute(smd_margined)
# 4. 計算形狀統計信息 # 0.5mm linear 二值化 mask(full extent,與 resampled_sitk_img 同 grid):
shape_stats = sitk.LabelShapeStatisticsImageFilter() # 原數據 5mm 切片上採樣 10x 到 0.5mm,最近邻會在邊界產生 10 體素厚的
shape_stats.Execute(relabeled_cc) # 階梯鋸齒;线性插值使邊界落在次體素位置(rotation 前的邊界更平滑)。
bin_lin = resample_img(sitk.Cast(bin_orig, sitk.sitkFloat32))
# 檢查是否有找到任何組件 arr = (sitk.GetArrayFromImage(bin_lin) > 0.5).astype(np.uint8)
if shape_stats.GetNumberOfLabels() < 1: 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 return None
largest_mask, bbox2 = cc_res
# 5. 獲取最大組件(標籤為 1)的邊界框
# 格式通常為 [x_start, y_start, z_start, x_size, y_size, z_size]
bbox2 = shape_stats.GetBoundingBox(1)
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.gz:0.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. roi(0.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 平滑 mask(0/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 分量 -> 0(z 分量保持 >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):
"""包裝 arr(size 可不同於 src_image):spacing/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,11 +169,15 @@ 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 獎勵
azi = azimuth_rotation(image2_path) if CBT:
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False) azi = float('nan')
alt = res['superior']['tilt_angle_deg'] alt = float('nan')
else:
azi = azimuth_rotation(image2_path)
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
alt = res['superior']['tilt_angle_deg']
# Move data to CPU for plotting # Move data to CPU for plotting
line_mask_l_cpu = line_mask_l.cpu().numpy() line_mask_l_cpu = line_mask_l.cpu().numpy()
@ -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 相对鏡稱面(法線 s,theta_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 為 nan),Azimuth/Altitude 直接顯示
# 最佳化出的原始角(=CSV 的 Raw_Azimuth / Raw_Altitude);TPS 沿用 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 刪除競態)時,重試會重建目錄
diameter_l, length_l, diameter_r, length_r, path = None
swarm_size, max_iter) def _save_fig_once():
nonlocal path
_retry_robust(os.makedirs, output_folder, exist_ok=True)
# 檔名只用 level(volume id 已在資料夾名裡,不重複)
path = save_with_unique_name(output_folder, level, way,
diameter_l, length_l, diameter_r, length_r,
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 L5、0770 L5)。
判定原理(見 imaging.orientation.diagnose_spinous_process):
棘突完整時,尖端是全椎體最後側的骨且位於中線 →
deficit(全骨最後側 - 中線帶最後側)≈ 0,後側中線窄帶有骨。
切除後,最後側骨偏到側方殘餘、後側中線空洞 → deficit 大 + rear3 ≈ 0。
no_spinous = deficit >= 4 vox 且 rear3 <= 20 vox(正常 level deficit 恒為 0)。
兩種模式:
quick(預設,掃全資料集用)→ 兩段式:
stage 1:axial 投影快速篩檢(~0.5s/level)。用「寬行(椎體行)」擬合
中線軌跡(對傾斜/旋轉穩健,不受後側殘片污染),找出「缺棘突候選」。
高召回(幾乎不漏)、但因純投影在傾斜脊椎上仍會有少數假候補。
stage 2:僅對候選跑 full(鏡稱面)驗證(~3s/候選),以 full 為準給出
最終判定(confirmed / false alarm)。全資料集 ~25min + 候數×3s。
full(指定單一 volume+level 時預設):跑鏡稱面 + 棘突/椎體完整分割,
另回報 VBODY 分割 mode(nosp_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_sdf(SDF 平滑遮罩)
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 2(quick 模式):候選用 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_sdf(SDF 平滑遮罩)
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 不再存檔,
# 改用旋轉後 _roi(CT)。
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 或末段(如 0005);level 為 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,8 +389,8 @@ 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_sdf(SDF 平滑遮罩)
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',
} }
def main(): 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' 欄位為鍵 upsert(TinyDB 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 快取(TinyDB,table 'images'):
以影像檔名前綴(= 輸出資料夾名,如 1.3.6.1.4.1.9328.50.4.0001、
liver_100、volume-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_path(append、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 同定義):
門檻 = 骨頭 mask(bin_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=VBODY、2=棘突、3=other bone、0=background)。
平面參數經 R 剛性旋轉並換元到輸出 grid 的局部座標。
旋轉來源 + 幾何模板:patient 未旋轉的 0.5mm _smd_resampled.nii.gz
(float、裁物件 bbox,SMD < 0.5 = 內部,與 _binary_sdf 同幾何);
缺失或格式不符時回退 _binary_nn.nii.gz(0.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 皆裁 bbox2;NN fallback 只有
# NN 遮罩一致);0.5mm 0/1,float 檔會被 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_nn(order=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_sdf(0.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 id(int),對照 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_sdf(0.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():
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()