refactor(imaging): improve orientation detection and segmentation robustness

Refactor the preprocessing and segmentation pipeline to handle AP orientation
variations and improve anatomical boundary detection.

Key changes include:
- Implement automated AP orientation detection in `process_single_image`
  to handle prone scans by flipping CT and labels when necessary.
- Enhance `segment_spinous_process` using a gap-based approach to identify
  the spinal canal, providing more stable thresholds for spinous process
  and vertebral body segmentation.
- Improve optimization search space by using the vertebral body (VBODY)
  projection for x/z bounding box calculation instead of the whole bone.
- Refactor `render_bone_figure` to unify 2D/3D visualization and support
  detailed anatomical coloring (VBODY, spinous process).
- Update `cl_score_torch_xfr` with more robust penalty handling for
  out-of-bone and null-voxel regions.
- Add `retry_robust` utility to handle transient NFS file system errors.
- Update `xfr_preprocess.py` to include anatomical segmentation coloring
  in rotated level visualizations.
This commit is contained in:
xfr 2026-09-07 18:46:06 +08:00
parent 523ec7ee16
commit 2ae08ac2cd
10 changed files with 976 additions and 812 deletions

View file

@ -24,7 +24,7 @@ ALLOWED_DIAMETERS = [
# 7.5, # 7.5,
] ]
ALLOWED_LENGTHS = [ ALLOWED_LENGTHS = [
25, # 25,
30, 30,
35, 35,
40, 40,

View file

@ -14,7 +14,7 @@ from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch,
from core.intersection import center_line_intersections_torch from core.intersection import center_line_intersections_torch
from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok, cl_score_torch_xfr from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok, cl_score_torch_xfr
from config.constant import OVERLAP_THRESH from config.constant import OVERLAP_THRESH
from visualization.res_plot_3d import res_plt_2_torch from visualization.res_bone_figure import render_bone_figure
LATERAL_REFINE_MIN_IN_BONE = 0.97 LATERAL_REFINE_MIN_IN_BONE = 0.97
@ -25,7 +25,7 @@ VBODY_ENTRY_EDGE = 4
def _makedirs_retry(path, retries=5, delay=0.5): def _makedirs_retry(path, retries=5, delay=0.5):
"""NFS 上建目錄重試(同 res_plot_3d._retry_robust 處理的瞬時錯誤)""" """NFS 上建目錄重試(同 utils.helpers.retry_robust 處理的瞬時錯誤)"""
for i in range(retries): for i in range(retries):
try: try:
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
@ -259,7 +259,7 @@ def run_pso_torch_xfr(
# 上終板面與椎體都在「完整 maskSP 移除前)」上計算: # 上終板面與椎體都在「完整 maskSP 移除前)」上計算:
# - 終板面由前側頂面擬合SP 移除不改變平面; # - 終板面由前側頂面擬合SP 移除不改變平面;
# - 椎體與 res_plt_2_torch 的 gold 顯示完全同 input / 同參數, # - 椎體與 render_bone_figure 的 gold 顯示完全同 input / 同參數,
# 確保顯示出來的椎體就是 loss 裡 VBODY 獎勵的區域。 # 確保顯示出來的椎體就是 loss 裡 VBODY 獎勵的區域。
# 棘突缺如laminectomymode='no_spinous')時不該把殘留後側要素 # 棘突缺如laminectomymode='no_spinous')時不該把殘留後側要素
# 當「棘突」移除(會鏟進椎體後側),入口面維持完整 mask。 # 當「棘突」移除(會鏟進椎體後側),入口面維持完整 mask。
@ -321,31 +321,32 @@ def run_pso_torch_xfr(
# 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)
# 脊椎中線:整段 (全體積) 骨頭 x 範圍的中點。 # x/z 搜索空間邊界:把 VBODY 椎體投影到 xz 平面,取該投影的 bounding box
# 不取單一行的原因 (0005 L5):椎體軸狀面旋轉時單行只罩到單側骨塊 # 再在 x 向切 L/R 兩 band + 中央缺口、z 向留 10%~90%(見下方 CBT bounds
# (x 1..76 / W=224 → x_mid≈0.17W)L/R 兩個 band 被壓到同一側。 # CBT 入口點應落在椎體上(左/右 band而非跨整段骨頭整段骨頭包含
# 不用鏡稱對稱軸的原因 (0001 L4):逐切面對稱軸會被肋、後側要素 # 肋、後側要素、橫突等極端x 範圍比椎體寬,會把 L/R band 往外推。
# 左右不對稱與椎體傾斜牽引 (69.0 vs 範圍中點 74.5),把 R band 內緣 # (不取單一行 / 不用鏡稱對稱軸的原因同前,舊註保留於下)。
# (x_mid+0.1W) 拉進中線棘突/椎板區R 側入口落在棘突上 (太靠內後)。 # VBODY mask 為 (z,y,x)np.any(..., axis=1) 折疊 y 得 xz 投影 (z,x)。
# Laminectomy 只移除中線後側要素,左右極端 x 位置不變, # x 範圍 = 有 VBODY 的欄(投影 axis=0 是 z沿 z 做 any
# 所以範圍中點同樣不受其影響,作為 L/R band 分割線比對稱軸穩定。 # z 範圍 = 有 VBODY 的行(投影 axis=1 是 x沿 x 做 any
x_with_nonzero = np.where(np.any(image2_array != 0, axis=(0, 1)))[0] # VBODY 分割失敗None / 全 0時退回整段骨頭 x/z 範圍。
x1 = x_with_nonzero[0] if vb_mask_np is not None and vb_mask_np.any():
x2 = x_with_nonzero[-1] vb_xz = np.any(vb_mask_np, axis=1) # (z, x) 投影
x1 = int(np.where(vb_xz.any(axis=0))[0][0])
x2 = int(np.where(vb_xz.any(axis=0))[0][-1])
z1 = int(np.where(vb_xz.any(axis=1))[0][0])
z2 = int(np.where(vb_xz.any(axis=1))[0][-1])
print(f"[BOUNDS] VBODY xz projection: z[{z1},{z2}] x[{x1},{x2}] "
f"(z_height={z2 - z1}, x_width={x2 - x1})")
else:
print("[BOUNDS] VBODY unavailable, falling back to whole-bone xz range")
x_with_nonzero = np.where(np.any(image2_array, axis=(0, 1)))[0]
x1 = int(x_with_nonzero[0])
x2 = int(x_with_nonzero[-1])
z1 = int(z_with_nonzero[0])
z2 = int(z_with_nonzero[-1])
x_width = x2 - x1 x_width = x2 - x1
z_height = z2 - z1
# print(x1,x2)
# exit()
# x_mid = (x1 + x2) / 2
# x1 = x_mid-image_shape[2]*.1
# x2 = x_mid+image_shape[2]*.1
z_sum = np.sum(image2_array, axis=(1, 2))
z_with_nonzero = np.where(z_sum > 0)[0]
z1 = z_with_nonzero[0]
z2 = z_with_nonzero[-1]
z_height = z2-z1
# print(x1,x2) # print(x1,x2)
# exit() # exit()
@ -363,14 +364,14 @@ def run_pso_torch_xfr(
# z_bounds = (0, image_shape[0]-1) # z_bounds = (0, image_shape[0]-1)
# z_bounds = (z1, (z1+z2)/2) # z_bounds = (z1, (z1+z2)/2)
# z_bounds = (.1*image_shape[0], .8*image_shape[0]) # z_bounds = (.1*image_shape[0], .8*image_shape[0])
z_bounds = (z1+z_height*.1, z1+z_height*.9) z_bounds = (0, z1+z_height*.8)
# x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1) # x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
# x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1) # x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
# x_bounds_right = (x2, image_shape[2]*.9) # x_bounds_right = (x2, image_shape[2]*.9)
# x_bounds_left = (image_shape[2]*.1, x1) # x_bounds_left = (image_shape[2]*.1, x1)
x_bounds_right = (x1+x_width*.6, +x_width*.9) x_bounds_left = (x1+x_width*.1, x1+x_width*.4)
x_bounds_left = (x1+x_width*.1, +x_width*.4) x_bounds_right = (x1+x_width*.6, x1+x_width*.9)
# 脊椎若被體積邊界切到(真正偏心、骨頭貼著左/右邊緣), # 脊椎若被體積邊界切到(真正偏心、骨頭貼著左/右邊緣),
# 對應那側的 x band 下限會 >= 上限PSO 會丟 "upper-bound must be greater"。 # 對應那側的 x band 下限會 >= 上限PSO 會丟 "upper-bound must be greater"。
@ -392,8 +393,8 @@ def run_pso_torch_xfr(
# 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))
azimuth_bounds_l = (98, 110) azimuth_bounds_l = (98, 105)
azimuth_bounds_r = (70, 82) azimuth_bounds_r = (75, 82)
altitude_bounds_l = (60, 65) altitude_bounds_l = (60, 65)
altitude_bounds_r = (60, 65) altitude_bounds_r = (60, 65)
@ -571,26 +572,20 @@ def run_pso_torch_xfr(
final_length_r = length final_length_r = length
def _plot_combined(d_l, l_l, d_r, l_r, pos_l, pos_r, total_t): def _plot_combined(d_l, l_l, d_r, l_r, pos_l, pos_r, total_t):
res_plt_2_torch( # volume / level 由 image2_path 反推(…/{vol}/rotated/{level}_*.nii.gz
render_bone_figure(
None, None,
spine_tensor, spine_tensor,
cortical_tensor, cortical_tensor,
image_shape,
image2_path,
folder, folder,
label_str, spacing=spacing,
d_l, way='CBT' if CBT else 'TPS',
l_l, best_position_l=pos_l, best_position_r=pos_r,
d_r, diameter_l=d_l, length_l=l_l,
l_r, diameter_r=d_r, length_r=l_r,
pos_l, image2_path=image2_path,
pos_r, device=device, grid=grid,
swarm_size, swarm_size=swarm_size, max_iter=max_iter, total_time=total_t,
max_iter,
total_t,
spacing,
CBT,
device,
grid,
) )
if side == 'both': if side == 'both':
@ -606,7 +601,7 @@ def run_pso_torch_xfr(
# 兩側可能在不同 GPU worker各自把結果寫 <level>_<side>.json # 兩側可能在不同 GPU worker各自把結果寫 <level>_<side>.json
# (先寫 tmp 再 os.replace對端讀到的一定是完整檔。先完成者看不到 # (先寫 tmp 再 os.replace對端讀到的一定是完整檔。先完成者看不到
# 對端檔就跳過;後完成者看到兩側齊了、搶到 plot lockO_EXCL # 對端檔就跳過;後完成者看到兩側齊了、搶到 plot lockO_EXCL
# 確保合併輸出只跑一次)才載入對端結果跑 res_plt_2_torch # 確保合併輸出只跑一次)才載入對端結果跑 render_bone_figure
# 3D 圖 + CSV 兩行,與 'both' 模式相同。json / lock 保留供事後 # 3D 圖 + CSV 兩行,與 'both' 模式相同。json / lock 保留供事後
# 檢查;若該側流程死在 plotting 中段,該 (volume, level) 重跑即可 # 檢查;若該側流程死在 plotting 中段,該 (volume, level) 重跑即可
# run_id 是新的一次,不會互相干擾)。 # run_id 是新的一次,不會互相干擾)。
@ -956,26 +951,20 @@ def run_pso_torch(
final_diameter_r = diameter final_diameter_r = diameter
final_length_r = length final_length_r = length
res_plt_2_torch( render_bone_figure(
spine_tensor, None, None,
cortical_tensor, spine_tensor,
image_shape, cortical_tensor,
image2_path, folder,
folder, spacing=spacing,
label_str, way='CBT' if CBT else 'TPS',
final_diameter_l, best_position_l=best_position_l, best_position_r=best_position_r,
final_length_l, diameter_l=final_diameter_l, length_l=final_length_l,
final_diameter_r, diameter_r=final_diameter_r, length_r=final_length_r,
final_length_r, image2_path=image2_path,
best_position_l, device=device, grid=grid,
best_position_r, swarm_size=swarm_size, max_iter=max_iter, total_time=total_time,
swarm_size, )
max_iter,
total_time,
spacing,
CBT,
device,
grid)
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
@ -990,7 +979,7 @@ from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values, create_coordinate_grid from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values, create_coordinate_grid
from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok
from config.constant import OVERLAP_THRESH from config.constant import OVERLAP_THRESH
from visualization.res_plot_3d import res_plt_2_torch from visualization.res_bone_figure import render_bone_figure
def run_de_torch( def run_de_torch(
label_str: str, label_str: str,
@ -1154,10 +1143,18 @@ def run_de_torch(
final_diameter_r = best_position_r[5] if optimize_size else diameter final_diameter_r = best_position_r[5] if optimize_size else diameter
final_length_r = best_position_r[6] if optimize_size else length final_length_r = best_position_r[6] if optimize_size else length
res_plt_2_torch( render_bone_figure(
spine_tensor, cortical_tensor, image_shape, image2_path, 'Output', label_str, None, None,
final_diameter_l, final_length_l, final_diameter_r, final_length_r, spine_tensor, cortical_tensor,
best_position_l, best_position_r, swarm_size, max_iter, total_time, spacing, CBT, device, grid 'Output',
spacing=spacing,
way='CBT' if CBT else 'TPS',
best_position_l=best_position_l, best_position_r=best_position_r,
diameter_l=final_diameter_l, length_l=final_length_l,
diameter_r=final_diameter_r, length_r=final_length_r,
image2_path=image2_path,
device=device, grid=grid,
swarm_size=swarm_size, max_iter=max_iter, total_time=total_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
@ -1324,10 +1321,18 @@ def run_nm_torch(
final_diameter_r = best_position_r[5] if optimize_size else diameter final_diameter_r = best_position_r[5] if optimize_size else diameter
final_length_r = best_position_r[6] if optimize_size else length final_length_r = best_position_r[6] if optimize_size else length
res_plt_2_torch( render_bone_figure(
spine_tensor, cortical_tensor, image_shape, image2_path, 'Output', label_str, None, None,
final_diameter_l, final_length_l, final_diameter_r, final_length_r, spine_tensor, cortical_tensor,
best_position_l, best_position_r, swarm_size, max_iter, total_time, spacing, CBT, device, grid 'Output',
spacing=spacing,
way='CBT' if CBT else 'TPS',
best_position_l=best_position_l, best_position_r=best_position_r,
diameter_l=final_diameter_l, length_l=final_length_l,
diameter_r=final_diameter_r, length_r=final_length_r,
image2_path=image2_path,
device=device, grid=grid,
swarm_size=swarm_size, max_iter=max_iter, total_time=total_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

@ -17,7 +17,7 @@ def cl_score_torch_xfr(
漸進式評分優先確保找到骨頭再改善細節 漸進式評分優先確保找到骨頭再改善細節
""" """
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() # in cortical
# VBODY 獎勵:螺絲落在 (cortical + VBODY) 內的 voxel 每個 100 分 # VBODY 獎勵:螺絲落在 (cortical + VBODY) 內的 voxel 每個 100 分
# 100 分項由純 cortical 擴展到 corticalVBODYcortical voxel 分數不變), # 100 分項由純 cortical 擴展到 corticalVBODYcortical voxel 分數不變),
# 其中落在 VBODY 的 voxel 每個再加 10 分 # 其中落在 VBODY 的 voxel 每個再加 10 分
@ -46,14 +46,14 @@ def cl_score_torch_xfr(
# if in_bone == 0: # if in_bone == 0:
# return float(not_in_bone*200) # return float(not_in_bone*200)
score += 20 * in_bone # 10 實在太低 score += 10 * in_bone # 10 實在太低
score += 100 * overlap score += 100 * overlap # in cortical
score += 100 * in_corti_vb # (cortical + VBODY) 每 voxel 100 score += 100 * in_corti_vb # (cortical + VBODY) 每 voxel 再加
score += 50 * in_vbody # VBODY 每 voxel 再加 10 score += 50 * in_vbody # VBODY 每 voxel 再加
# score -= 2000 * max(0, not_in_bone-10) # score -= 1000 * not_in_bone
# score -= 1000 * max(0, null_vox2-10) score -= 1000 * max(0, not_in_bone-10)
score -= 1000 * not_in_bone # score -= 2000 * null_vox2
score -= 2000 * null_vox2 score -= 2000 * max(0, null_vox2-10)
return float(-score) return float(-score)

View file

@ -249,9 +249,12 @@ def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
w = max(min_band, band_frac * s 全寬) w = max(min_band, band_frac * s 全寬)
2) 前後方向平面內兩軸 (u, v) |y| 分量大者 2) 前後方向平面內兩軸 (u, v) |y| 分量大者
正規化成 +AP = 後側本資料系 y 往前遞增後側 = y 小側 正規化成 +AP = 後側本資料系 y 往前遞增後側 = y 小側
3) 中線帶的 AP 分佈呈兩大叢椎體在前椎弓/棘突在後 3) 中線帶的 AP 分佈呈兩大叢椎體在前椎弓/棘突在後中間椎管
以兩叢間的 AP 谷底為界AP >= 谷底 的中線帶 voxel = 棘突含中線椎弓 空隙連續 <5% 峰值的安靜 bin最長空隙定位= 椎管
無明顯谷底如骨橋fallback 取中線帶後側 15% 棘突閾值取空隙後側端AP >= 空隙後側端 = 後側叢含中線椎弓
回傳的椎體閾值取空隙體側邊谷 segment_vertebral_body 使用
兩閾值被椎管隔開椎體後側緣 fringe 不會被誤判成棘突
無明顯空隙如骨橋fallback 取中線帶後側 15%
4) 中線帶側緣補回_expand_spinous_runsexpand_cap帶由鏡稱面定義 4) 中線帶側緣補回_expand_spinous_runsexpand_cap帶由鏡稱面定義
棘突楔若略偏中線側緣薄條會留在帶外成為 other bone每條 (y, z) 棘突楔若略偏中線側緣薄條會留在帶外成為 other bone每條 (y, z)
線把棘突 run 向左右各補至多 expand_cap bone voxel 線把棘突 run 向左右各補至多 expand_cap bone voxel
@ -301,39 +304,58 @@ def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
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
th_sp = None
mode = 'fallback' mode = 'fallback'
if hi - lo >= 10: if hi - lo >= 10:
hist, edges = np.histogram(aps, bins=range(lo, hi + 1)) hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
csum = np.concatenate([[0], np.cumsum(hist)]) csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1] total = csum[-1]
peak = hist.max() peak = hist.max()
best_i, best_score = None, -1.0 # 收集連續安靜 bin<5% 峰值)的「空隙」,要求兩側質量都夠
for i in range(len(hist)): # >= min_mass_frac * total取最長者 = 椎管。
if hist[i] >= 0.05 * peak: # (舊式單 bin score=min(前,後) 最大化:後側叢質量較小時恆落在
# 空隙的椎體側第一安靜 bin椎體後側緣被傾斜鏡稱帶斜切出的
# 1~2 體素 fringe 會 >= 該閾值而誤判成棘突 → 圖上椎體內出現
# 棘突點。)
quiet = hist < 0.05 * peak
runs = []
i = 0
while i < len(hist):
if not quiet[i]:
i += 1
continue continue
if csum[i] < min_mass_frac * total or (total - csum[i + 1]) < min_mass_frac * total: j = i
continue while j + 1 < len(hist) and quiet[j + 1]:
score = min(csum[i], total - csum[i + 1]) j += 1
if score > best_score: left = int(csum[i])
best_score, best_i = score, i right = int(total - csum[j + 1])
if best_i is not None: if left >= min_mass_frac * total and right >= min_mass_frac * total:
post_frac = (total - csum[best_i + 1]) / total runs.append((j - i + 1, min(left, right), i, j))
i = j + 1
if runs:
# 最長空隙勝(平手取兩側質量大者);正常椎管是最長安靜區間
runs.sort(key=lambda r: (r[0], r[1]), reverse=True)
_, _, i0, i1 = runs[0]
post_frac = (total - csum[i1 + 1]) / total
if post_frac < 0.05 and abs(a) >= MIRROR_MIN_LR: if post_frac < 0.05 and abs(a) >= MIRROR_MIN_LR:
# 谷底後側叢只剩小殘片(<5% 中線帶質量)=棘突幾乎全除 # 空隙後側叢只剩小殘片(<5% 中線帶質量)=棘突幾乎全除
# (部分切除殘餘):當作缺如,椎體切分不採用此閾值。 # (部分切除殘餘):當作缺如,椎體切分不採用此閾值。
# 僅在鏡稱面左右為主時才算數(斜板層時 post_frac 不可信) # 僅在鏡稱面左右為主時才算數(斜板層時 post_frac 不可信)
info['mode'] = 'no_spinous' info['mode'] = 'no_spinous'
info['post_frac'] = float(post_frac) info['post_frac'] = float(post_frac)
return None, None, info return None, None, info
th = float(0.5 * (edges[best_i] + edges[best_i + 1])) th = float(0.5 * (edges[i0] + edges[i0 + 1])) # 回傳值:體側邊谷,椎體 AP 切點
th_sp = float(edges[i1 + 1]) # 空隙後側端:棘突由此開始
mode = 'gap' mode = 'gap'
if th is None: if th is None:
th = float(np.quantile(aps, 0.85)) th = float(np.quantile(aps, 0.85))
sp_mask = np.zeros(m.shape, dtype=bool) sp_mask = np.zeros(m.shape, dtype=bool)
sel = mid & (ap >= th) # 棘突用空隙後側端 th_sp無空隙 fallback 時退回 quantile th
# 椎體切點(回傳 th維持身體側兩者在椎管兩端、mask 不相觸。
sel = mid & (ap >= (th_sp if th_sp is not None else th))
sp_mask[zz[sel], yy[sel], xx[sel]] = True sp_mask[zz[sel], yy[sel], xx[sel]] = True
sp_mask = _expand_spinous_runs(sp_mask, m, cap=expand_cap) sp_mask = _expand_spinous_runs(sp_mask, m, cap=expand_cap)
info.update(n_sp=int(sp_mask.sum()), ap_thresh=th, mode=mode) info.update(n_sp=int(sp_mask.sum()), ap_thresh=th, ap_thresh_sp=th_sp, mode=mode)
return sp_mask, th, info return sp_mask, th, info
@ -429,6 +451,98 @@ def _ap_axis(sym):
return u_ap return u_ap
def anterior_y_side(mask_zyx, band_frac=0.06, min_band=6.0, min_ratio=1.3,
min_side_frac=0.05, min_voxels=100):
"""判定 index 系 (z,y,x) 中椎體前側(椎體塊所在側)在哪個 y 端:
'y_max' : 前側 = y 大側與本套件各函式預設慣例一致 = y = y
'y_min' : 前側 = y 小側前後翻轉prone 伏位掃描個案
呼叫端應把 CT label y 方向翻轉使輸出方向與其他個案一致
None : 無法判定骨量太少鏡稱面非左右為主前後方向局部極大
0019 L3/L4 情形無明顯椎管安靜區間或前後質量差不夠
無法判定時呼叫端維持預設方向寧可不翻轉不誤翻轉
原理先取最佳鏡稱面best_symmetry_plane左右對稱構造其結果不受
前後翻轉影響取該面的中線帶 segment_spinous_process band 定義
在帶內計算前後坐標面內 |y| 分量大者為 AP 此處用無向版本
指向 y 大側 AP 直方圖單椎體在帶內的 AP 分佈有兩大叢
前側椎體塊後側棘突/椎板以椎管最長安靜區間
segment_spinous_process 同一套閾值分隔椎體塊質量恆明顯大於
棘突/椎板量測正常 case ratio 1.4~2.5質量大側 = 前側
"""
m = np.asarray(mask_zyx) > 0
if int(m.sum()) < int(min_voxels):
return None
# 裁到骨頭 bbox輸入若是整顆 volume如 0.5mm 重取樣 label 的單層
# maskbest_symmetry_plane 的初始 search 中心 c0 = 體積盒中心會偏離
# 椎體;裁切後 c0 落在椎體上(與各輸出 bbox 裁切遮罩同條件)。
# 純平移不影響 y 端方向判定。
zz0, yy0, xx0 = np.nonzero(m)
z0, z1 = int(zz0.min()), int(zz0.max())
y0, y1 = int(yy0.min()), int(yy0.max())
x0, x1 = int(xx0.min()), int(xx0.max())
m = m[z0:z1 + 1, y0:y1 + 1, x0:x1 + 1]
try:
sym = best_symmetry_plane(m)
except Exception:
return None
if abs(sym['normal'][0]) < MIRROR_MIN_LR:
# 鏡稱面非左右為主(前後 coronal 局部極大)→ 中線帶失效,無法判定前後
return None
a, b, c, d = sym['plane']
zz, yy, xx = np.nonzero(m)
X = xx.astype(np.float64)
Y = yy.astype(np.float64)
Z = zz.astype(np.float64)
s = X * a + Y * b + Z * c - d
w = max(float(min_band), float(band_frac) * float(s.max() - s.min()))
band = np.abs(s) <= w
Xb, Yb, Zb = X[band], Y[band], Z[band]
if Xb.size < int(min_voxels):
return None
u = np.array(sym['u'])
v = np.array(sym['v'])
ap = u if abs(u[1]) >= abs(v[1]) else v
if ap[1] < 0:
ap = -ap # 無向:指向 y 大側
aproj = Xb * ap[0] + Yb * ap[1] + Zb * ap[2]
lo = int(np.floor(aproj.min()))
hi = int(np.ceil(aproj.max()))
if hi - lo < 10:
return None
hist, edges = np.histogram(aproj, bins=range(lo, hi + 1))
csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1]
peak = hist.max()
# 最長安靜區間(<5% 峰值,兩側各 >= min_side_frac 質量)= 椎管
quiet = hist < 0.05 * peak
runs = []
i = 0
while i < len(hist):
if not quiet[i]:
i += 1
continue
j = i
while j + 1 < len(hist) and quiet[j + 1]:
j += 1
left = int(csum[i])
right = int(total - csum[j + 1])
if left >= min_side_frac * total and right >= min_side_frac * total:
runs.append((j - i + 1, min(left, right), i, j))
i = j + 1
if not runs:
return None
runs.sort(key=lambda r: (r[0], r[1]), reverse=True)
_, _, i0, i1 = runs[0]
m_low = int(csum[i0]) # 空隙 y 小側叢質量
m_high = int(total - csum[i1 + 1]) # 空隙 y 大側叢質量
if min(m_low, m_high) < min_side_frac * total:
return None
ratio = max(m_low, m_high) / float(max(1, min(m_low, m_high)))
if ratio < float(min_ratio):
return None
return 'y_max' if m_high > m_low else 'y_min'
def _full_ap_valley(aps, min_side_frac=0.15, max_ratio=0.85, smooth=3): def _full_ap_valley(aps, min_side_frac=0.15, max_ratio=0.85, smooth=3):
"""終板下骨體 AP 分佈的平滑谷底最深相對谷底sm[i] 對鄰近峰的最小比值), """終板下骨體 AP 分佈的平滑谷底最深相對谷底sm[i] 對鄰近峰的最小比值),
要求兩側各有 >= min_side_frac 的質量拒絕對小尾巴的偽谷底 要求兩側各有 >= min_side_frac 的質量拒絕對小尾巴的偽谷底
@ -483,7 +597,8 @@ def _posterior_min_threshold(aps, rear_frac=0.40, min_side_frac=0.08, smooth=3):
return float(0.5 * (edges[best_i] + edges[best_i + 1])) 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): def segment_vertebral_body(mask_zyx, sym, endplate, ap_thresh, sp_mode, margin=2.0,
sliver_frac=0.20, lat_margin=3.0):
""" """
以兩平面從 3D bone mask (z, y, x) 切出椎體前側中央主體塊 以兩平面從 3D bone mask (z, y, x) 切出椎體前側中央主體塊
1) best_upper_endplate_plane 的上終板面法線朝上 a·x+b·y+c·z=d 1) best_upper_endplate_plane 的上終板面法線朝上 a·x+b·y+c·z=d
@ -501,10 +616,18 @@ def segment_vertebral_body(mask_zyx, sym, endplate, ap_thresh, sp_mode, margin=2
_posterior_min_threshold _posterior_min_threshold
c) 否則終板下整體 AP 分佈的平滑谷底_full_ap_valley c) 否則終板下整體 AP 分佈的平滑谷底_full_ap_valley
處理中線骨橋等中線搜尋 fallback 的情形 處理中線骨橋等中線搜尋 fallback 的情形
d) 最後 fallbackAP 分佈 55 百分位可能切進椎體內會打 WARNING d) 最後 fallbackAP 分佈 55 百分位可能切進椎體內會打 WARNING
椎體 = AP < 切點切點之前側且終板下側的 bone 椎體 = AP < 切點切點之前側且終板下側的 bone
3) 側向包絡lateral clip椎體是終板下的中央塊橫突及弓根
側緣向鏡稱面法線方向延伸其前緣恰好跨過 AP 切點椎體後側
兩角處只靠兩平面會把橫突前段算進椎體 AP 切點較遠
AP 排除靠切點後側 sliver_frac 的帶帶內正是橫突前緣
量出椎體自身的側向鏡稱面寬度再把候選裁到該寬度
+ margin橫突遠超出椎體寬多達數厘米被去除椎體本體
含最寬處因其落在窗內或 margin 範圍保留
回傳 (vb_mask (z,y,x) bool, ap_thresh, info dict) 回傳 (vb_mask (z,y,x) bool, ap_thresh, info dict)
資料不足或上終板面缺位時 vb_mask = Noneinfo['mode'] 說明原因 資料不足或上終板面缺位時 vb_mask = Noneinfo['mode'] 說明原因
info['lat_clip'] 記錄實際施加的側向切點未施加時為 None
""" """
m = np.asarray(mask_zyx) > 0 m = np.asarray(mask_zyx) > 0
zz, yy, xx = np.nonzero(m) zz, yy, xx = np.nonzero(m)
@ -544,12 +667,45 @@ def segment_vertebral_body(mask_zyx, sym, endplate, ap_thresh, sp_mode, margin=2
if th is None: if th is None:
th = float(np.quantile(aps, 0.55)) th = float(np.quantile(aps, 0.55))
sel = below & (ap < th) sel = below & (ap < th)
if not sel.any():
info['mode'] = 'no_body_voxels'
return None, None, info
# 側向包絡(見 docstring 3逐 zendplate 法線層)在離 AP 切點較遠
# 的 AP 窗量該層椎體自身側向(鏡稱面 signed distance寬度裁掉橫突
# 前緣(其遠超出該層椎體寬);層寬隨 z 變化(椎體不同高度寬度不同),
# 單一 3D 包絡會太寬(被最寬層撐大、中層橫突殘留)。量測不足的 z 用
# 相鄰 z 的封包插值np.interp 端點延伸)。
# sliver_frac靠切點後側、排除出量測窗的 AP 帶比例(橫突前緣所在)。
lat_clip = None
a_s, b_s, c_s, d_s = sym['plane']
lat = X * a_s + Y * b_s + Z * c_s - d_s
ap_anter = float(ap[sel].min())
ext = float(th - ap_anter)
if ext > 6.0:
body_sel = sel & (ap <= th - float(sliver_frac) * ext)
nz = m.shape[0]
zid = zz.astype(np.int64)
cnt = np.zeros(nz, dtype=np.int64)
np.add.at(cnt, zid[body_sel], 1)
zidx = np.flatnonzero(cnt >= 30)
if zidx.size > 0:
zv = zid[body_sel]
lv = lat[body_sel]
lo_z = np.full(nz, np.inf)
hi_z = np.full(nz, -np.inf)
np.minimum.at(lo_z, zv, lv)
np.maximum.at(hi_z, zv, lv)
lo_f = np.interp(np.arange(nz), zidx, lo_z[zidx])
hi_f = np.interp(np.arange(nz), zidx, hi_z[zidx])
m_lat = max(float(lat_margin), 0.04 * ext)
sel = sel & (lat >= lo_f[zid] - m_lat) & (lat <= hi_f[zid] + m_lat)
lat_clip = (float(lo_f.min()) - m_lat, float(hi_f.max()) + m_lat)
if not sel.any(): if not sel.any():
info['mode'] = 'no_body_voxels' info['mode'] = 'no_body_voxels'
return None, None, info return None, None, info
vb_mask = np.zeros(m.shape, dtype=bool) vb_mask = np.zeros(m.shape, dtype=bool)
vb_mask[zz[sel], yy[sel], xx[sel]] = True vb_mask[zz[sel], yy[sel], xx[sel]] = True
info.update(n_vb=int(sel.sum()), ap_thresh=th, mode=mode) info.update(n_vb=int(sel.sum()), ap_thresh=th, mode=mode, lat_clip=lat_clip)
return vb_mask, th, info return vb_mask, th, info
def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=4.0, def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=4.0,

View file

@ -1,4 +1,5 @@
import os import os
import numpy as np
import SimpleITK as sitk import SimpleITK as sitk
from imaging.resample import resample_img from imaging.resample import resample_img
from imaging.affine import standardize_affine from imaging.affine import standardize_affine
@ -7,6 +8,18 @@ import json
import glob import glob
from config.constant import LABEL_MAP from config.constant import LABEL_MAP
from imaging.nifti_io import sitk_to_nibabel, nibabel_to_sitk from imaging.nifti_io import sitk_to_nibabel, nibabel_to_sitk
from imaging.orientation import anterior_y_side
def flip_y_sitk(img):
"""index 系 y 軸array axis 1前後方向翻轉
只翻數據spacing/origin/direction 等幾何不變即整顆體積的前後
朝向在 index 系翻轉y 大側 <-> y 小側 supine / prone 個案
統一前後慣例前側 = y 大側"""
arr = np.flip(sitk.GetArrayFromImage(img), axis=1)
out = sitk.GetImageFromArray(arr)
out.CopyInformation(img)
return out
@ -111,6 +124,68 @@ def process_single_image(image_path, label_path, output_dir_base=None, max_z_spa
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)
# 前後AP方向判定本流程最終輸出慣例是前側 = y 大側(後 = y 小側)。
# 但 prone伏位掃描個案經 standardize_affine 後前側落在 y 小側
# CTSpine1K colon 0003、0075、0460...,全 dataset 約 1%
# 不修正時上終板 / 棘突 / 椎體分割與 rotated/ 對齊全部反掉。
#
# 判定(對每個 allowed level 的 0.5mm label 個別做、再票決——不能
# 直接 union 多層腰椎前凸lordosis下各層椎體在 AP 投影會散開,
# 椎管「空隙」被其它層的骨填掉):
# 1) anterior_y_side 回傳工作體積0.5mm 重取樣、standardize_affine
# 之前grid 中椎體塊所在的 y 端y_min / y_max / None
# 2) standardize_affinenibabel 端)會在輸出 affine y 分量 < 0 時
# 再翻一次 y。注意 nibabel affine 與 SimpleITK direction 的 y 分
# 量符號相反NIfTI RAS <-> SITK LPS所以
# standardize_affine 會翻 y <=> direction[4] > 0
# 最終 y 端 = pre_side若會翻 y 則 y_min<->y_max 互換);
# 3) 最終前側會落 y 小側時,現在先對 CT 與 label 做 y 翻轉
# (純 index 翻轉、幾何不變),與 standardize_affine 的翻轉
# 組成淨效果,使所有輸出與其它個案同方向。
# 無法判定 / 投票平手時維持原方向(寧可不翻、不誤翻)。
# 判定結果存入 metadata dbap_flip重跑免重算。
ap_flip = (meta or {}).get("ap_flip")
if ap_flip is None:
# standardize_affine 是否會翻轉 ynibabel affine[1,1] < 0
# 等价於 sitk direction[4] > 0兩者符號相反
std_flips_y = resampled_sitk_img.GetDirection()[4] > 0
arr_lbl = sitk.GetArrayFromImage(resampled_sitk_lbl)
votes = []
for n in allowed_label_list:
side = anterior_y_side(arr_lbl == n)
if side is not None:
votes.append(side)
n_min = votes.count("y_min")
n_max = votes.count("y_max")
if n_min > n_max:
pre_side = "y_min"
elif n_max > n_min:
pre_side = "y_max"
else:
pre_side = None
if pre_side is None:
ap_flip = False
print(f"AP orientation undetermined for {name} (votes={votes}); "
f"proceeding with default orientation (anterior = large y)")
else:
final_side = (pre_side if not std_flips_y
else ("y_min" if pre_side == "y_max" else "y_max"))
ap_flip = final_side == "y_min"
print(f"AP orientation for {name}: pre={pre_side} "
f"(std_flips_y={std_flips_y}) -> final={final_side} "
f"[votes={votes}], flip={ap_flip}")
if metadata_cache is not None:
metadata_cache.put(name, {"ap_flip": bool(ap_flip)})
if ap_flip:
# label原解析度 raw label也要翻seg_bone 的主遮罩鏈
#_binary / SMD / _binary_sdf是用 original_label= label
# 算的,不是用 0.5mm resampled label漏翻時翻轉不生效。
label = flip_y_sitk(label)
resampled_sitk_img = flip_y_sitk(resampled_sitk_img)
resampled_sitk_lbl = flip_y_sitk(resampled_sitk_lbl)
print(f"AP orientation corrected for {name}: CT and label flipped "
f"along y; outputs unified to anterior = large y")
# 建立每個檔案的輸出資料夾 # 建立每個檔案的輸出資料夾
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", "")

View file

@ -1,7 +1,23 @@
import errno
import os import os
import time
import numpy as np import numpy as np
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
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 get_unique_filepath(path: str) -> str: def get_unique_filepath(path: str) -> str:
""" """
如果檔案已存在自動加 _1, _2... 避免覆蓋 如果檔案已存在自動加 _1, _2... 避免覆蓋

View file

@ -1,3 +1,4 @@
import csv
import os import os
from datetime import datetime from datetime import datetime
@ -11,9 +12,10 @@ import numpy as np
import SimpleITK as sitk import SimpleITK as sitk
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
from imaging.orientation import (best_symmetry_plane, best_upper_endplate_plane, from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour,
segment_spinous_process, segment_vertebral_body) best_symmetry_plane, best_upper_endplate_plane,
from utils.helpers import get_unique_filepath segment_spinous_process, segment_vertebral_body)
from utils.helpers import get_unique_filepath, retry_robust, save_with_unique_name
# 體積吸收渲染Beer-Lambert與 res_plot_3d 相同: # 體積吸收渲染Beer-Lambert與 res_plot_3d 相同:
@ -235,38 +237,105 @@ def _rotate_plane_params(plane, R, c_xyz):
return out return out
def _mask_to_array(m):
"""骨頭 / 皮質遮罩path (nifti) / (z,y,x) ndarray / torch tensor
-> (z,y,x) boolNone / 缺檔 / -> None"""
if m is None:
return None
if isinstance(m, str):
return _load_mask(m)
arr = m.cpu().numpy() if hasattr(m, 'cpu') else m
arr = np.asarray(arr)
if arr.size == 0:
return None
b = arr > 0
return b if int(b.sum()) > 0 else None
def render_bone_figure(volume_id, level, binary_path, cortical_path, def render_bone_figure(volume_id, level, binary_path, cortical_path,
base_folder="/mnt/1248/open2/cyrou/Output", base_folder="/mnt/1248/open2/cyrou/Output",
spacing=(0.5, 0.5, 0.5), way="CBT", spacing=(0.5, 0.5, 0.5), way="CBT",
planes_only=False, output_path=None, rotation=None): planes_only=False, output_path=None, rotation=None,
"""繪製單一 (volume, level) 骨頭 X-ray 圖(不畫螺絲)。 best_position_l=None, best_position_r=None,
diameter_l=None, length_l=None,
diameter_r=None, length_r=None,
image2_path=None, device=None, grid=None,
swarm_size=None, max_iter=None, total_time=None,
write_csv=True):
"""統一骨頭 X-ray 四視角圖(合併原 render_bone_figure + res_plt_2_torch
四視角預設/axial/coronal/sagittal 四視角預設 / axial俯視 XY/ 冠狀後視/ 矢狀
內容皮質 vs 鬆質吸收骨椎體(gold)棘突(purple) 內容皮質 vs 鬆質吸收骨椎體(gold)棘突(purple獨立層覆蓋椎體)
中矢狀鏡稱面(orange)上終板面(green)無圓柱/中心線 中矢狀鏡稱面(orange)上終板面(green)螺絲模式另畫中心線()
+ 圓柱L darkcyan / R blueo 層粉
繪製採固定分層不依深度排序基底骨 < VBODY < 棘突 < 終板 < 鏡稱面 < 螺絲
planes_only=True只畫骨頭 + 中矢狀鏡稱面 + 上終板面 骨骼輸入
不做棘突 / 椎體VBODY分割 binary_path 骨頭遮罩path (nifti) (z,y,x) ndarray / torch tensor
output_path若給定直接存到該路徑含自動加 _1/_2 防覆蓋 cortical_path 皮質遮罩同型path / ndarray / tensorNone / 缺檔
否則存到 base_folder/{date}/{volume_id}/{volume_id} {level}_{way}.png 時全部視為鬆質骨
rotation(R, c_xyz)給定時把骨頭點雲與兩個平面都依 R 旋轉 c_xyz volume_id/level 可為 None由路徑反推/{vol}/rotated/{level}_*.nii.gz
用於畫對齊後rotated的平面圖R 作用於 (x,y,z) 向量 rotated 的上一層 = vol
cortical_path皮質遮罩路徑 (z,y,x) 0/1 陣列須與 binary_path grid image2_path 未給定時 = binary_pathpath 情形TPS 模式用其算 2D
None / 缺檔時全部視為鬆質骨 參考 az/altAzimuth/Altitude 用相對角
平面 / 分割
planes_only=True 只畫平面不做棘突 / 椎體VBODY分割
rotation=(R, c_xyz) 點雲與平面同依 R 旋轉 c_xyz用於對齊後
rotated的平面圖R 作用於 (x,y,z) 向量
螺絲模式一律做分割R loss VBODY 獎勵gold
顯示需椎體planes_only 被忽略
螺絲可選給定 best_position_l/r 時啟用optimizer 的輸出
best_position = (z, y, x, az, alt[, d, L])須搭配該側 diameter/length
device/grid 供圓柱生成swarm_size/max_iter/total_time 供圖面註記
write_csv 時在 base_folder/{date}/{vol}/output.csv 依欄位名稱 append
L/R 兩行 schema 自動重映射
輸出
output_path 給定 -> 存該路徑含自動加 _1/_2 防覆蓋None
螺絲模式: base_folder/{date}/{vol}/{level}_{way}_L{d}_{l}_R{d}_{l}_{swarm}_{iter}.png
否則: base_folder/{date}/{vol}/{vol} {level}_{way}.png
回傳存檔路徑無有效骨頭遮罩時回傳 None 回傳存檔路徑無有效骨頭遮罩時回傳 None
""" """
spine = _load_mask(binary_path) # ---- 路徑反推volume_id / level / image2_path ----
if image2_path is None and isinstance(binary_path, str):
image2_path = binary_path
if (volume_id is None or level is None) and image2_path:
parent = os.path.dirname(image2_path)
vol_p = (os.path.basename(os.path.dirname(parent))
if os.path.basename(parent) == 'rotated'
else os.path.basename(parent))
volume_id = volume_id or vol_p
level = level or os.path.basename(image2_path).split('_')[0]
if volume_id is None or level is None:
raise ValueError('volume_id / level unknown: 需提供 image2_path'
'或顯式傳 volume_id 與 level')
# ---- 螺絲:決定畫哪些側 ----
side_cfg = {'L': (best_position_l, diameter_l, length_l, 'Left'),
'R': (best_position_r, diameter_r, length_r, 'Right')}
screw_sides = []
for s in ('L', 'R'):
pos, d, L, _cn = side_cfg[s]
if pos is None:
continue
if d is None or L is None:
raise ValueError(f'{s}best_position 須搭配 diameter / length')
screw_sides.append(s)
screw_mode = bool(screw_sides)
CBT = str(way).upper() == 'CBT'
# ---- 骨骼 / 皮質 ----
spine = _mask_to_array(binary_path)
if spine is None: if spine is None:
print(f"[skip] {volume_id} {level}: 無/空骨頭遮罩 {binary_path}") print(f"[skip] {volume_id} {level}: 無/空骨頭遮罩 {binary_path}")
return None return None
cortical = _mask_to_array(cortical_path)
if isinstance(cortical_path, np.ndarray):
cortical = cortical_path > 0
else:
cortical = _load_mask(cortical_path)
if cortical is None: if cortical is None:
cortical = np.zeros_like(spine) cortical = np.zeros_like(spine)
image_shape = spine.shape
voxel_mm = float(spacing[0]) voxel_mm = float(spacing[0])
alpha_cortical = 1.0 - np.exp(-BONE_MU_CORTICAL * voxel_mm) alpha_cortical = 1.0 - np.exp(-BONE_MU_CORTICAL * voxel_mm)
alpha_trabecular = 1.0 - np.exp(-BONE_MU_TRABECULAR * voxel_mm) alpha_trabecular = 1.0 - np.exp(-BONE_MU_TRABECULAR * voxel_mm)
@ -279,26 +348,57 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
sym = best_symmetry_plane(spine) sym = best_symmetry_plane(spine)
symp = best_upper_endplate_plane(spine) symp = best_upper_endplate_plane(spine)
if planes_only: if planes_only and not screw_mode:
# 只做方向平面,不做棘突 / 椎體分割 # 只做方向平面,不做棘突 / 椎體分割
vb_mask = None
sp_corti = sp_trab = None sp_corti = sp_trab = None
vb_corti = vb_trab = None vb_corti = vb_trab = None
sp_info = {} sp_info = {}
vb_info = {} vb_info = {}
else: else:
# 棘突:鏡稱面中線帶(|s|<=w且在 AP 谷底之後側;棘突缺如
#(先前 laminectomy / 棘突切除)時 sp_mask=None不標示
sp_mask, sp_th, sp_info = segment_spinous_process(spine, sym) sp_mask, sp_th, sp_info = segment_spinous_process(spine, sym)
if sp_mask is not None and sp_mask.any(): if sp_info.get('mode') == 'no_spinous':
if screw_mode:
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.get('deficit', float('nan')):.1f} voxel "
f"({sp_info.get('deficit', 0.0) * 0.5:.1f} mm), "
f"rear3={sp_info.get('rear3')} voxel, top_off={top_off} "
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]
if screw_mode:
sp_n_bone = max(int(spine.sum()), 1)
print(f"[SPINOUS] n={sp_info['n_sp']} "
f"({100.0 * sp_info['n_sp'] / sp_n_bone:.1f}% of bone) "
f"band=+/-{sp_info['band_w']:.1f} voxel "
f"AP>={sp_info['ap_thresh']:.1f} mode={sp_info['mode']}")
else: else:
sp_corti = sp_trab = None sp_corti = sp_trab = None
vb_mask, vb_th, vb_info = segment_vertebral_body(spine, sym, symp, sp_th, sp_info["mode"]) # 椎體:上終板之下 + 中線 AP 谷底之前側(對齊基準系下與 loss 的
# VBODY 獎勵完全同 input / 同參數,顯示的椎體=計分的椎體)
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(): if vb_mask is not None and vb_mask.any():
vb_corti = vb_mask[z_corti, y_corti, x_corti] vb_corti = vb_mask[z_corti, y_corti, x_corti]
vb_trab = vb_mask[z_trab, y_trab, x_trab] vb_trab = vb_mask[z_trab, y_trab, x_trab]
if screw_mode:
print(f"[VBODY] n={vb_info['n_vb']} "
f"({100.0 * vb_info['n_vb'] / max(int(spine.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: else:
vb_corti = vb_trab = None vb_corti = vb_trab = None
if screw_mode:
print(f"[VBODY] skipped: {vb_info['mode']}")
# ---- 骨頭點雲(體積吸收)---- # ---- 骨頭點雲(體積吸收)----
x_bone = np.concatenate([x_corti, x_trab]) x_bone = np.concatenate([x_corti, x_trab])
@ -317,16 +417,23 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
bone_rgba = bone_rgba[::BONE_SUBSAMPLE] bone_rgba = bone_rgba[::BONE_SUBSAMPLE]
bone_size = bone_size[::BONE_SUBSAMPLE] bone_size = bone_size[::BONE_SUBSAMPLE]
# VBODY / 棘突的 voxel flagscorti+trab 接合陣列上);兩 mask 重合時
# 歸棘突(與 label map 2 覆蓋 1 一致)
vb_flag = None
sp_flag = None
if vb_corti is not None: if vb_corti is not None:
vb_flag = np.concatenate([vb_corti, vb_trab]) vb_flag = np.concatenate([vb_corti, vb_trab]).astype(bool)
if BONE_SUBSAMPLE > 1: if BONE_SUBSAMPLE > 1:
vb_flag = vb_flag[::BONE_SUBSAMPLE] vb_flag = vb_flag[::BONE_SUBSAMPLE]
bone_rgba[vb_flag] = to_rgba("gold", 0.95)
if sp_corti is not None: if sp_corti is not None:
sp_flag = np.concatenate([sp_corti, sp_trab]) sp_flag = np.concatenate([sp_corti, sp_trab]).astype(bool)
if BONE_SUBSAMPLE > 1: if BONE_SUBSAMPLE > 1:
sp_flag = sp_flag[::BONE_SUBSAMPLE] sp_flag = sp_flag[::BONE_SUBSAMPLE]
bone_rgba[sp_flag] = to_rgba("purple", 0.95) if vb_flag is None:
vb_flag = np.zeros(len(x_bone), dtype=bool)
if sp_flag is None:
sp_flag = np.zeros(len(x_bone), dtype=bool)
vb_flag &= ~sp_flag
# ---- 旋轉對齊(若給定):骨頭點雲與平面同旋轉 ---- # ---- 旋轉對齊(若給定):骨頭點雲與平面同旋轉 ----
if rotation is not None: if rotation is not None:
@ -340,6 +447,146 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
if symp is not None: if symp is not None:
symp = _rotate_plane_params(symp, R, c_xyz) symp = _rotate_plane_params(symp, R, c_xyz)
# ---- 拆三層:基底骨 / VBODY (gold) / 棘突 (purple) ----
# x_bone 保留完整點雲(含 VBODY / SP供下方平面 patch 算範圍
#VBODY 前側是整顆骨最前緣,剔除後綠色終板 patch 會縮小);
# mpl 3D scatter 在同一 collection 內依深度排序 markers
# 「棘突覆蓋椎體、螺絲覆蓋骨頭」改以固定 zorder 分層達成(見 _fill_ax
base_idx = ~(vb_flag | sp_flag)
x_base, y_base, z_base = x_bone[base_idx], y_bone[base_idx], z_bone[base_idx]
rgba_base, size_base = bone_rgba[base_idx], bone_size[base_idx]
vb_idx = vb_flag & ~sp_flag
x_vb, y_vb, z_vb = x_bone[vb_idx], y_bone[vb_idx], z_bone[vb_idx]
x_sp, y_sp, z_sp = x_bone[sp_flag], y_bone[sp_flag], z_bone[sp_flag]
# ---- 螺絲:圓柱 + 中心線 + losslazy import torch / core.*
# 無螺絲的 preprocess 路徑不會載入 torch----
side_info = {}
side_azlat, side_acep = {}, {}
theta_v = tau_y = tau_x = float('nan')
azi = alt = float('nan')
x_screw = y_screw = z_screw = None
screw_rgba = screw_size = None
if screw_mode:
if device is None:
raise ValueError('螺絲模式需要 devicetorch device')
spacing = list(spacing) # core.cylinder 以 list 比對 spacing
import torch
from core.cylinder import (generate_cylinder_n_torch,
generate_cylinder_o_torch)
from core.intersection import center_line_intersections_torch
from core.scoring import cl_score_torch, cl_score_torch_xfr
# TPS 用 2D 參考角CBT 無 2D 參考az/alt 為 nan
if not CBT:
if image2_path:
azi = float(azimuth_rotation(image2_path))
alt = float(analyze_vertebral_tilt_contour(
image2_path, edge_type='superior',
show_plot=False, debug=False)['superior']['tilt_angle_deg'])
else:
print('[warn] TPS 模式缺 image2_path'
'Azimuth/Altitude 退回原始角')
spine_tensor = torch.from_numpy(spine.astype(np.uint8)).to(device)
cortical_tensor = torch.from_numpy(cortical.astype(np.uint8)).to(device)
vbody_tensor = None
if vb_mask is not None and vb_mask.any():
# R 側 loss 使用與 PSO 目標函數相同的 VBODY 獎勵
#(回報分數與優化一致)
vbody_tensor = torch.from_numpy(vb_mask.astype(np.uint8)).to(device=device)
# 角度註記(與 CSV 同參數):
# Azimuth_Lateral = 螺絲在鏡稱面內相對 AP 軸的發散角(+ = L 側往外,− = R 側)
# Altitude_Endplate = 螺絲相對上終板面的仰角
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
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
alt_cep = (90.0 - float(np.degrees(np.arccos(
np.clip(d_v @ e_n, -1.0, 1.0))))
if e_n is not None else float('nan'))
return az_lateral, alt_cep
for s in screw_sides:
pos, d, L, _cn = side_cfg[s]
# 螺絲方向向量(與 generate_cylinder_n_torch 同慣例):
# d = (cos(az)·sin(alt), sin(az)·sin(alt), cos(alt))alt = 相對 +z 的極角
cyl_n = generate_cylinder_n_torch(d, L, pos[0], pos[1], pos[2],
pos[3], pos[4],
image_shape, spacing, device, grid)
cyl_o = generate_cylinder_o_torch(d, L, pos[0], pos[1], pos[2],
pos[3], pos[4],
image_shape, spacing, device, grid)
inter, line_mask = center_line_intersections_torch(
pos[0], pos[1], pos[2], pos[3], pos[4],
int(L), spine_tensor, spacing, device)
if s == 'L':
loss = cl_score_torch(cortical_tensor, spine_tensor,
cyl_n, cyl_o, inter)
else:
loss = cl_score_torch_xfr(cortical_tensor, spine_tensor,
cyl_n, cyl_o, inter,
vbody_tensor=vbody_tensor)
cyl_points = int(torch.sum(cyl_n).item())
ovc = (100.0 * int(((cortical_tensor == 1) & (cyl_n == 1)).sum().item())
/ cyl_points) if cyl_points else 0.0
ovb = (100.0 * int(((spine_tensor == 1) & (cyl_n == 1)).sum().item())
/ cyl_points) if cyl_points else 0.0
side_info[s] = {
'line': np.where(line_mask.cpu().numpy() == 1),
'cyl_n': np.where(cyl_n.cpu().numpy() == 1),
'cyl_o': np.where(cyl_o.cpu().numpy() == 1),
'loss': float(loss),
'inter': inter,
'cyl_points': cyl_points,
'ovc': ovc,
'ovb': ovb,
}
side_azlat[s], side_acep[s] = _rel_angles(float(pos[3]), float(pos[4]))
# 螺絲點雲(存在的側接合):中心線紅、圓柱 n = L darkcyan / R blue、o = 粉。
# 點序固定為舊 res_plt_2_torch 的接合序 [L線, R線, L_n, L_o, R_n, R_o]
# mpl 3D 的 per-point 深度排序對近同深 markers 以輸入序決 tie換序會讓
# 同一點集產生亞像素級抗鋸齒邊界差A/B 實測 ~0.07% 邊緣像素),
# 固定點序保持與舊圖位元級一致。
_CYL_COLOR = {'L': 'darkcyan', 'R': 'blue'}
parts_x, parts_y, parts_z, parts_c, parts_s = [], [], [], [], []
for s in ('L', 'R'): # 中心線
if s not in side_info:
continue
z_p, y_p, x_p = side_info[s]['line']
parts_x.append(x_p); parts_y.append(y_p); parts_z.append(z_p)
parts_c.append(_rgba_block(len(x_p), 'r', 1.0))
parts_s.append(np.full(len(x_p), 3))
for s in ('L', 'R'): # 圓柱:各側 n 後 oL_n, L_o, R_n, R_o
if s not in side_info:
continue
for key, color, size in (('cyl_n', None, 36), ('cyl_o', 'pink', 36)):
z_p, y_p, x_p = side_info[s][key]
parts_x.append(x_p); parts_y.append(y_p); parts_z.append(z_p)
parts_c.append(_rgba_block(len(x_p), _CYL_COLOR[s] if color is None else color, 1.0))
parts_s.append(np.full(len(x_p), size))
if parts_x:
x_screw = np.concatenate(parts_x)
y_screw = np.concatenate(parts_y)
z_screw = np.concatenate(parts_z)
screw_rgba = np.concatenate(parts_c)
screw_size = np.concatenate(parts_s)
# ---- 中矢狀(鏡稱)平面 ---- # ---- 中矢狀(鏡稱)平面 ----
_a, _b, _c, _d = sym["plane"] _a, _b, _c, _d = sym["plane"]
_n = np.array([_a, _b, _c]) _n = np.array([_a, _b, _c])
@ -376,22 +623,41 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
fig = plt.figure(figsize=(12, 12)) fig = plt.figure(figsize=(12, 12))
legend_handles = [] legend_handles = []
for s in ("L", "R"):
if s in side_info:
legend_handles.append(Line2D([], [], marker="o", ls="", ms=6,
color="darkcyan" if s == "L" else "blue",
label=f"Cylinder({s})"))
if vb_corti is not None: if vb_corti is not None:
legend_handles.append(Line2D([], [], marker="o", ls="", ms=6, color="gold", label="VertebralBody")) 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", label="Spinous")) legend_handles.append(Line2D([], [], marker="o", ls="", ms=6, color="purple", label="SpinousProcess"))
def _fill_ax(ax): def _fill_ax(ax):
# 固定分層(關 depth zorder否則半透明骨頭會被重繪到螺絲上方
# 基底骨(5) < VBODY gold(6) < 棘突 purple(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_base, y_base, z_base, c=rgba_base, s=size_base, marker="o")
sc_bone.set_zorder(5) sc_bone.set_zorder(5)
plane = ax.plot_surface(_Xp, _Yp, _Zp, color="orange", alpha=0.30, if x_vb.size:
linewidth=1.0, edgecolor="orange", rstride=1, cstride=1) sc_vb = ax.scatter(x_vb, y_vb, z_vb, c=to_rgba("gold", 0.95),
plane.set_zorder(8) s=BONE_MARKER_SIZE, marker="o")
sc_vb.set_zorder(6)
if x_sp.size:
sc_sp = ax.scatter(x_sp, y_sp, z_sp, c=to_rgba("purple", 0.95),
s=BONE_MARKER_SIZE, marker="o")
sc_sp.set_zorder(6.5)
if _EX is not None: if _EX is not None:
ep = ax.plot_surface(_EX, _EY, _EZ, color="green", alpha=0.35, ep = ax.plot_surface(_EX, _EY, _EZ, color="green", alpha=0.35,
linewidth=1.0, edgecolor="green", rstride=1, cstride=1) linewidth=1.0, edgecolor="green", rstride=1, cstride=1)
ep.set_zorder(7) ep.set_zorder(7)
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 x_screw is not None:
sc_screw = ax.scatter(x_screw, y_screw, z_screw,
c=screw_rgba, s=screw_size, marker="o")
sc_screw.set_zorder(10)
ax1 = fig.add_subplot(221, projection="3d") ax1 = fig.add_subplot(221, projection="3d")
_fill_ax(ax1) _fill_ax(ax1)
@ -407,7 +673,8 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
ax2.legend(handles=legend_handles) ax2.legend(handles=legend_handles)
ax3 = fig.add_subplot(223, projection="3d") ax3 = fig.add_subplot(223, projection="3d")
ax3.view_init(elev=0, azim=90, roll=0) # 後視圖:相機在 y 後側x 軸畫面左小右大
ax3.view_init(elev=0, azim=-90, roll=0)
_fill_ax(ax3) _fill_ax(ax3)
ax3.set_xlabel("X-axis"); ax3.set_ylabel("Y-axis"); ax3.set_zlabel("Z-axis") ax3.set_xlabel("X-axis"); ax3.set_ylabel("Y-axis"); ax3.set_zlabel("Z-axis")
set_axes_equal_3d(ax3) set_axes_equal_3d(ax3)
@ -419,33 +686,156 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
set_axes_equal_3d(ax4) set_axes_equal_3d(ax4)
label_str = f"{volume_id} {level}" label_str = f"{volume_id} {level}"
base_tag = "planes only" if planes_only else "no screws" if screw_mode:
if rotation is not None: fig.text(0.5, 0.98, f"{label_str} Best Position", ha="center", fontsize=15)
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"))) d_l = f"{diameter_l} mm, {length_l} mm" if diameter_l is not None else ''
if planes_only: d_r = f"{diameter_r} mm, {length_r} mm" if diameter_r is not None else ''
ep_ratio = float(symp.get("inlier_ratio", float("nan"))) if symp is not None else float("nan") t_s = f"Total time = {total_time:.2f} s" if total_time is not None else ''
info = (f"sym_ratio={ratio:.3f} " fig.text(
f"endplane_ratio={ep_ratio:.3f} " 0.5, 0.44,
f"endplate={'yes' if symp is not None else 'no'}") f"L: Diameter = {d_l}, "
f"R: Diameter = {d_r}, "
f"Swarm size = {swarm_size}, Iteration = {max_iter}, {t_s}",
ha="center", fontsize=12
)
def _ang_segs(az_v, alt_v, azlat_v, acep_v):
# CBT 沒有 2D 參考面Azimuth/Altitude 直接用最佳化出的原始角;
# TPS 沿用 2D 參考之相對角。終板面擬合失敗nan時該段自動略過。
segs = [f"Azimuth = {az_v:.2f}", f"Altitude = {alt_v:.2f}"]
if np.isfinite(azlat_v):
segs.append(f"Azimuth_Lateral = {azlat_v:.2f}")
if np.isfinite(acep_v):
segs.append(f"Altitude_Endplate = {acep_v:.2f}")
return ', '.join(segs)
def _side_footer(s, y):
pos, _d, _L, cn_name = side_cfg[s]
if pos is None:
return
if CBT:
segs = _ang_segs(float(pos[3]), float(pos[4]),
side_azlat[s], side_acep[s])
else:
segs = _ang_segs(90.0 - float(pos[3]) - azi,
90.0 - float(pos[4]) - alt,
side_azlat[s], side_acep[s])
di = side_info[s]
cb_ratio = di['ovc'] / di['ovb'] if di['ovb'] else 0.0
fig.text(
0.5, y,
f"{cn_name} : Position = ({pos[2]:.2f}, {pos[1]:.2f}, {pos[0]:.2f}), "
f"{segs}, "
f"Intersection = {di['inter']}, "
f"Score = {di['ovc']:.2f} / {di['ovb']:.2f} / {cb_ratio:.2f}",
ha="center", fontsize=8
)
_side_footer('L', 0.03)
_side_footer('R', 0.01)
else: else:
info = (f"sym_ratio={ratio:.3f} " base_tag = "planes only" if planes_only else "no screws"
f"spinous={sp_info.get('n_sp', 0)} ({sp_info.get('mode', '?')}) " if rotation is not None:
f"vertebral_body={vb_info.get('n_vb', 0)} ({vb_info.get('mode', '?')})") base_tag += ", rotated"
fig.text(0.5, 0.03, info, ha="center", fontsize=8) 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() fig.tight_layout()
date_str = datetime.now().strftime("%Y%m%d")
output_folder = os.path.join(base_folder, date_str, volume_id)
retry_robust(os.makedirs, output_folder, exist_ok=True)
# ---- CSV螺絲模式L/R 兩行;舊 schema 檔案依欄位名稱重映射後改寫,
# 避免 append 欄位錯位 ----
if screw_mode and write_csv:
csv_path = os.path.join(output_folder, 'output.csv')
# CBT 模式下恆為 nan 的 2D 參考欄不寫入 CSV
headers = [
'Label', 'Side', 'Diameter', 'Length', 'Swarm_Size', 'Max_Iter',
'Position_XYZ', 'Raw_Azimuth', 'Raw_Altitude',
'Intersections', 'Best_Loss', 'cyl_points', 'Overlap_Cortical', 'Overlap_Bone',
'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}"
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:
with retry_robust(open, csv_path, 'a', newline='') as csvfile:
writer = csv.writer(csvfile)
if not file_exists:
writer.writerow(headers)
for s in ('L', 'R'):
pos, d, L, _cn = side_cfg[s]
if pos is None:
continue
di = side_info[s]
writer.writerow([
label_str, s, d, L, swarm_size, max_iter,
f"({pos[2]:.2f}, {pos[1]:.2f}, {pos[0]:.2f})",
f"{pos[3]:.2f}", f"{pos[4]:.2f}",
di['inter'], f"{di['loss']:.2f}", di['cyl_points'],
f"{di['ovc']:.2f}", f"{di['ovb']:.2f}",
f"{(di['ovc'] / di['ovb'] if di['ovb'] else 0):.2f}",
_fmt(theta_v), _fmt(tau_y), _fmt(tau_x),
_fmt(side_azlat[s]), _fmt(side_acep[s]),
f"{total_time:.2f}" if total_time is not None else ''
])
print(f"[CSV Saved] {csv_path}")
except Exception as e:
print(f"[Error] Failed to write CSV: {e}")
if output_path is not None: if output_path is not None:
path = get_unique_filepath(output_path) path = get_unique_filepath(output_path)
elif screw_mode:
path = save_with_unique_name(
output_folder, level, way,
diameter_l if diameter_l is not None else '',
length_l if length_l is not None else '',
diameter_r if diameter_r is not None else '',
length_r if length_r is not None else '',
swarm_size if swarm_size is not None else '',
max_iter if max_iter is not None else '',
)
else: else:
date_str = datetime.now().strftime("%Y%m%d") path = get_unique_filepath(
output_folder = os.path.join(base_folder, date_str, volume_id) os.path.join(output_folder, f"{volume_id} {level}_{way}.png"))
output_file = os.path.join(output_folder, f"{volume_id} {level}_{way}.png")
path = get_unique_filepath(output_file) retry_robust(os.makedirs, os.path.dirname(path) or ".", exist_ok=True)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) retry_robust(fig.savefig, path, dpi=200, bbox_inches="tight")
fig.savefig(path, dpi=200, bbox_inches="tight") print("[Saved figure]", path)
plt.close(fig) plt.close(fig)
return path return path

View file

@ -1,47 +1,14 @@
import torch import torch
import numpy as np import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import to_rgba
from matplotlib.lines import Line2D
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import os
import errno
import time
from datetime import datetime
import csv
from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch, snap_to_discrete_values from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values
from core.intersection import center_line_intersections_torch from core.scoring import compute_overlap_ratio_from_cylinder_mask
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,
best_symmetry_plane, best_upper_endplate_plane,
segment_spinous_process, segment_vertebral_body)
from utils.helpers import save_with_unique_name
# Volume absorption 渲染Beer-Lambert每 voxel 不透明度 = 1 - exp(-mu * voxel_width)
# 骨頭核心厚度達 70-90 voxel沿視線堆疊會使任何 per-voxel alpha 累積成不透明。
# 因此以「抽稀 (SUBSAMPLE) 降低堆疊數量」+「低 mu 控制每點吸收」兩項共同調出淡薄 X-ray 陰影,
# 同時保留皮質 / 鬆質的吸收入射差異mu 比值)。
BONE_MU_CORTICAL = 0.02 # 1/mm → 每 voxel = 1-exp(-0.02*0.5) ~ 0.010
BONE_MU_TRABECULAR = 0.005 # 1/mm → 每 voxel = 1-exp(-0.005*0.5) ~ 0.0025
BONE_MARKER_SIZE = 3.0 # 骨骼散點點面積 (pt^2);略大以補償抽稀後的顆粒感
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,
cubes as cubes, etc. cubes are cubes, etc.
""" """
x_limits = ax.get_xlim3d() x_limits = ax.get_xlim3d()
y_limits = ax.get_ylim3d() y_limits = ax.get_ylim3d()
@ -65,604 +32,25 @@ def set_axes_equal_3d(ax):
except AttributeError: except AttributeError:
pass pass
def res_plt_2_torch(
spine_tensor: torch.Tensor, def res_plt_2_torch(spine_tensor, cortical_tensor, image_shape, image2_path,
cortical_tensor: torch.Tensor, base_folder, label_str, diameter_l, length_l,
image_shape: tuple[int, int, int], diameter_r, length_r, best_position_l, best_position_r,
image2_path: str, swarm_size, max_iter, total_time, spacing, CBT, device,
base_folder: str, grid):
label_str: str, """相容入口(舊簽名):實作已合併進 res_bone_figure.render_bone_figure。
diameter_l: float, label_str / image_shape 不再使用volume / level image2_path 反推"""
length_l: float, from visualization.res_bone_figure import render_bone_figure
diameter_r: float, return render_bone_figure(
length_r: float, None, None, spine_tensor, cortical_tensor, base_folder,
best_position_l: list[float], spacing=spacing, way='CBT' if CBT else 'TPS',
best_position_r: list[float], best_position_l=best_position_l, best_position_r=best_position_r,
swarm_size: int, diameter_l=diameter_l, length_l=length_l,
max_iter: int, diameter_r=diameter_r, length_r=length_r,
total_time: float, image2_path=image2_path, device=device, grid=grid,
spacing: list[float], swarm_size=swarm_size, max_iter=max_iter, total_time=total_time,
CBT: bool,
device: torch.device,
grid=None
) -> None:
"""
Same plotting function as before, but it uses torch-based generation
and then moves data to CPU for matplotlib 3D scatter.
"""
cyl_l = generate_cylinder_n_torch(
diameter_l,
length_l,
best_position_l[0],
best_position_l[1],
best_position_l[2],
best_position_l[3],
best_position_l[4],
image_shape,
spacing,
device,
grid
) )
cyl_lo = generate_cylinder_o_torch(
diameter_l,
length_l,
best_position_l[0],
best_position_l[1],
best_position_l[2],
best_position_l[3],
best_position_l[4],
image_shape,
spacing,
device,
grid
)
cyl_r = generate_cylinder_n_torch(
diameter_r,
length_r,
best_position_r[0],
best_position_r[1],
best_position_r[2],
best_position_r[3],
best_position_r[4],
image_shape,
spacing,
device,
grid
)
cyl_ro = generate_cylinder_o_torch(
diameter_r,
length_r,
best_position_r[0],
best_position_r[1],
best_position_r[2],
best_position_r[3],
best_position_r[4],
image_shape,
spacing,
device,
grid
)
intersections_l, line_mask_l = center_line_intersections_torch(
best_position_l[0],
best_position_l[1],
best_position_l[2],
best_position_l[3],
best_position_l[4],
int(length_l),
spine_tensor,
spacing,
device
)
loss_l = cl_score_torch(cortical_tensor, spine_tensor, cyl_l, cyl_lo, intersections_l)
intersections_r, line_mask_r = center_line_intersections_torch(
best_position_r[0],
best_position_r[1],
best_position_r[2],
best_position_r[3],
best_position_r[4],
int(length_r),
spine_tensor,
spacing,
device
)
# loss_r = cl_score_torch(cortical_tensor, spine_tensor, cyl_r, cyl_ro, intersections_r)
# loss_r 放在下方 VBODY mask 計算之後:計入與 PSO 目標函數相同的 VBODY voxel 獎勵
if CBT:
azi = float('nan')
alt = float('nan')
else:
azi = azimuth_rotation(image2_path)
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
line_mask_l_cpu = line_mask_l.cpu().numpy()
line_mask_r_cpu = line_mask_r.cpu().numpy()
cyl_l_cpu = cyl_l.cpu().numpy()
cyl_lo_cpu = cyl_lo.cpu().numpy()
cyl_r_cpu = cyl_r.cpu().numpy()
cyl_ro_cpu = cyl_ro.cpu().numpy()
spine_cpu = spine_tensor.cpu().numpy()
z_lin1, y_lin1, x_lin1 = np.where(line_mask_l_cpu == 1)
z_lin2, y_lin2, x_lin2 = np.where(line_mask_r_cpu == 1)
z_cyl_l1, y_cyl_l1, x_cyl_l1 = np.where(cyl_l_cpu == 1)
z_cyl_l2, y_cyl_l2, x_cyl_l2 = np.where(cyl_lo_cpu == 1)
z_cyl_r1, y_cyl_r1, x_cyl_r1 = np.where(cyl_r_cpu == 1)
z_cyl_r2, y_cyl_r2, x_cyl_r2 = np.where(cyl_ro_cpu == 1)
# 骨頭 voxel 依「體積吸收」分成兩組:皮質(高不透明度)與鬆質(低不透明度)
cortical_cpu = cortical_tensor.cpu().numpy()
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)
z_corti, y_corti, x_corti = np.where((spine_cpu == 1) & (cortical_cpu == 1))
z_trab, y_trab, x_trab = np.where((spine_cpu == 1) & (cortical_cpu == 0))
# 中矢狀面:骨頭的最佳鏡稱面,一般平面 a·x + b·y + c·z = d法線方向任意
sym = best_symmetry_plane(spine_cpu)
# 棘突:鏡稱面中線帶(|s|<=w且在 AP 谷底之後側的骨 voxel換不同顏色標示
# 棘突缺如(先前 laminectomy / 棘突切除)時 sp_mask=None不標示。
sp_mask, sp_th, sp_info = segment_spinous_process(spine_cpu, sym)
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_trab = sp_mask[z_trab, y_trab, x_trab]
sp_n_bone = max(int(spine_cpu.sum()), 1)
print(f"[SPINOUS] n={sp_info['n_sp']} "
f"({100.0 * sp_info['n_sp'] / sp_n_bone:.1f}% of bone) "
f"band=+/-{sp_info['band_w']:.1f} voxel AP>={sp_info['ap_thresh']:.1f} "
f"mode={sp_info['mode']}")
else:
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 外觀:骨頭合成一個半透明體積吸收點雲(下方);
# 螺絲(中心線 + 圓柱 + 入口軌跡延长)合成一個全不透明點雲,永遠畫在骨頭之上
def _rgba_block(n, color, a):
arr = np.empty((n, 4))
arr[:] = to_rgba(color)
arr[:, 3] = a
return arr
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)
# 抽稀:降低堆疊不透明度以呈現淡薄 X-ray 陰影
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]
# 平面 patch 的範圍用「完整骨頭」(含 VBODY / SP
# 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:
f = f[::BONE_SUBSAMPLE]
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])
y_screw = np.concatenate([y_lin1, y_lin2, y_cyl_l1, y_cyl_l2, y_cyl_r1, y_cyl_r2])
z_screw = np.concatenate([z_lin1, z_lin2, z_cyl_l1, z_cyl_l2, z_cyl_r1, z_cyl_r2])
_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_all - _p0[0], y_bone_all - _p0[1], z_bone_all - _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_all - _ep0[0], y_bone_all - _ep0[1], z_bone_all - _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]
screw_rgba = np.concatenate([
_rgba_block(len(x_lin1), 'r', 1.0),
_rgba_block(len(x_lin2), 'r', 1.0),
_rgba_block(len(x_cyl_l1), 'darkcyan', 1.0),
_rgba_block(len(x_cyl_l2), 'pink', 1.0),
_rgba_block(len(x_cyl_r1), 'blue', 1.0),
_rgba_block(len(x_cyl_r2), 'pink', 1.0),
])
screw_size = np.concatenate([
np.full(len(x_lin1), 3), np.full(len(x_lin2), 3),
np.full(len(x_cyl_l1), 36), np.full(len(x_cyl_l2), 36),
np.full(len(x_cyl_r1), 36), np.full(len(x_cyl_r2), 36),
])
fig = plt.figure(figsize=(12, 12))
legend_handles = [
Line2D([], [], marker='o', ls='', ms=6, color='darkcyan', label='Cylinder(L)'),
Line2D([], [], marker='o', ls='', ms=6, color='blue', label='Cylinder(R)'),
]
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='SpinousProcess'))
def _fill_ax(ax):
# X-ray 外觀:關閉 mplot3d 依深度自動排序 zorder否則半透明骨頭會被重繪到
# 螺絲上方);改為固定分層:
# 基底骨 zorder=5 < VBODY 6 < SP 6.5 < 終板 7 < 鏡稱面 8 < 螺絲 10
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)
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.set_zorder(10)
# 中矢狀面(理論左右對稱切分面):半透明橘色平面 x = x_mid
# 平面邊緣畫橘色線,讓 axial / 正視(側看時)也能清楚看到切分線
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)
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)
cyl_points_l = torch.sum(cyl_l).item()
cyl_points_r = torch.sum(cyl_r).item()
overlap_l = ((cortical_tensor == 1) & (cyl_l == 1)).sum().item()
overlap_r = ((cortical_tensor == 1) & (cyl_r == 1)).sum().item()
overlap_b_l = ((spine_tensor == 1) & (cyl_l == 1)).sum().item()
overlap_b_r = ((spine_tensor == 1) & (cyl_r == 1)).sum().item()
overlap_cortical_l = (overlap_l / cyl_points_l) * 100 if cyl_points_l else 0.0
overlap_cortical_r = (overlap_r / cyl_points_r) * 100 if cyl_points_r else 0.0
overlap_vertebral_l = (overlap_b_l / cyl_points_l) * 100 if cyl_points_l else 0.0
overlap_vertebral_r = (overlap_b_r / cyl_points_r) * 100 if cyl_points_r else 0.0
cb_ratio_l = overlap_cortical_l/overlap_vertebral_l if overlap_vertebral_l else 0.0
cb_ratio_r = overlap_cortical_r/overlap_vertebral_r if overlap_vertebral_r else 0.0
user_altitude_l = 90 - best_position_l[4] - alt
user_altitude_r = 90 - best_position_r[4] - alt
user_azimuth_l = 90 - best_position_l[3] - azi
user_azimuth_r = 90 - best_position_r[3] - azi
# 螺絲方向向量(與 generate_cylinder_n_torch 同慣例):
# d = (cos(az)·sin(alt), sin(az)·sin(alt), cos(alt))alt = 相对 +z 的極角
# Azimuth 相对鏡稱面(法線 stheta_v = atan2(sy, sx)
# Azimuth_Lateral = az - theta_v - 90 (面內 AP 軸起的帶號發散角,+ = L 側往外,− = R 側)
# Altitude 相對上終板面(法線 e朝上
# Altitude_Cephalad_Endplate = 90 - (d 與 e 的夾角)
sym_n = np.asarray(sym['normal'], dtype=float)
theta_v = float(np.degrees(np.arctan2(sym_n[1], sym_n[0])))
if symp is not None:
e_n = np.asarray(symp['normal'], dtype=float)
e_n = e_n / np.linalg.norm(e_n)
tau_y = float(np.degrees(np.arctan2(e_n[1], e_n[2])))
tau_x = float(np.degrees(np.arctan2(e_n[0], e_n[2])))
else:
e_n = None
tau_y = float('nan')
tau_x = float('nan')
def _rel_angles(az_deg, alt_deg):
az_r = np.radians(az_deg)
alt_r = np.radians(alt_deg)
d_v = np.array([np.cos(az_r) * np.sin(alt_r),
np.sin(az_r) * np.sin(alt_r),
np.cos(alt_r)])
az_lateral = az_deg - theta_v - 90.0
if e_n is not None:
ang_norm = float(np.degrees(np.arccos(np.clip(d_v @ e_n, -1.0, 1.0))))
alt_cep = 90.0 - ang_norm
else:
alt_cep = float('nan')
return az_lateral, alt_cep
azlat_l, acep_l = _rel_angles(best_position_l[3], best_position_l[4])
azlat_r, acep_r = _rel_angles(best_position_r[3], best_position_r[4])
date_str = datetime.now().strftime("%Y%m%d")
# 旋轉後影像存在 <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)
_retry_robust(os.makedirs, output_folder, exist_ok=True)
csv_path = os.path.join(output_folder, 'output.csv')
# 欄位標題 (Header)。CBT 模式下恆為 nan 的 2D 參考欄
# (Azimuth_Diff / Altitude_Diff / User_Azimuth / User_Altitude) 不寫入 CSV。
headers = [
'Label', 'Side', 'Diameter', 'Length', 'Swarm_Size', 'Max_Iter',
'Position_XYZ', 'Raw_Azimuth', 'Raw_Altitude',
'Intersections', 'Best_Loss', 'cyl_points', 'Overlap_Cortical', 'Overlap_Bone',
'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:
with _retry_robust(open, csv_path, 'a', newline='') as csvfile:
writer = csv.writer(csvfile)
# 新檔案寫入 Header
if not file_exists:
writer.writerow(headers)
# 寫入 Left 數據
writer.writerow([
label_str,
'L',
diameter_l,
length_l,
swarm_size,
max_iter,
# 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[3]:.2f}",
f"{best_position_l[4]:.2f}",
intersections_l,
f"{loss_l:.2f}",
cyl_points_l,
f"{overlap_cortical_l:.2f}",
f"{overlap_vertebral_l:.2f}",
f"{(overlap_cortical_l/overlap_vertebral_l if overlap_vertebral_l!=0 else 0):.2f}",
_fmt(theta_v),
_fmt(tau_y),
_fmt(tau_x),
_fmt(azlat_l),
_fmt(acep_l),
f"{total_time:.2f}"
])
# 寫入 Right 數據
writer.writerow([
label_str,
'R',
diameter_r,
length_r,
swarm_size,
max_iter,
# 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[3]:.2f}",
f"{best_position_r[4]:.2f}",
intersections_r,
f"{loss_r:.2f}",
cyl_points_r,
f"{overlap_cortical_r:.2f}",
f"{overlap_vertebral_r:.2f}",
f"{(overlap_cortical_r/overlap_vertebral_r if overlap_vertebral_r!=0 else 0):.2f}",
_fmt(theta_v),
_fmt(tau_y),
_fmt(tau_x),
_fmt(azlat_r),
_fmt(acep_r),
f"{total_time:.2f}"
])
print(f"[CSV Saved] {csv_path}")
except Exception as e:
print(f"[Error] Failed to write CSV: {e}")
fig.text(0.5, 0.98, f'{label_str} Best Position', ha='center', fontsize=15)
fig.text(
0.5, 0.44,
f'L: Diameter = {diameter_l} mm, {length_l} mm, '
f'R: Diameter = {diameter_r} mm, {length_r} mm, '
f'Swarm size = {swarm_size}, Iteration = {max_iter}, Total time = {total_time:.2f} s',
ha='center', fontsize=12
)
# 角度註記CBT 沒有 2D 參考面user az/alt 為 nanAzimuth/Altitude 直接顯示
# 最佳化出的原始角CSV 的 Raw_Azimuth / Raw_AltitudeTPS 沿用 2D 參考之相對角。
# 另補上相對骨骼的角度(與 CSV 同參數):
# Azimuth_Lateral = 螺絲在鏡稱面內相對 AP 軸的發散角(+ = L 側往外,− = R 側)
# Altitude_Endplate = 螺絲相對上終板面的仰角
# 終板面擬合失敗nan時該段自動略過。
def _fig_angle_segs(az, alt, azlat, acep):
segs = [f'Azimuth = {az:.2f}', f'Altitude = {alt:.2f}']
if np.isfinite(azlat):
segs.append(f'Azimuth_Lateral = {azlat:.2f}')
if np.isfinite(acep):
segs.append(f'Altitude_Endplate = {acep:.2f}')
return ', '.join(segs)
if CBT:
_ang_l = _fig_angle_segs(float(best_position_l[3]), float(best_position_l[4]), azlat_l, acep_l)
_ang_r = _fig_angle_segs(float(best_position_r[3]), float(best_position_r[4]), azlat_r, acep_r)
else:
_ang_l = _fig_angle_segs(user_azimuth_l, user_altitude_l, azlat_l, acep_l)
_ang_r = _fig_angle_segs(user_azimuth_r, user_altitude_r, azlat_r, acep_r)
fig.text(
0.5, 0.03,
f'Left : Position = ({best_position_l[2]:.2f}, {best_position_l[1]:.2f}, {best_position_l[0]:.2f}), '
f'{_ang_l}, '
f'Intersection = {intersections_l}, Score = {overlap_cortical_l:.2f} / {overlap_vertebral_l:.2f} / {cb_ratio_l:.2f}',
ha='center', fontsize=8
)
fig.text(
0.5, 0.01,
f'Right : Position = ({best_position_r[2]:.2f}, {best_position_r[1]:.2f}, {best_position_r[0]:.2f}), '
f'{_ang_r}, '
f'Intersection = {intersections_r}, Score = {overlap_cortical_r:.2f} / {overlap_vertebral_r:.2f} / {cb_ratio_r:.2f}',
ha='center', fontsize=8
)
fig.tight_layout()
date_str = datetime.now().strftime("%Y%m%d")
file_name = os.path.basename(image2_path)
level = file_name.split('_')[0]
output_folder = os.path.join(base_folder, date_str, patient_id)
if CBT == True:
way = 'CBT'
else:
way = 'TPS'
# 建目錄 + 存檔一起重試輸出樹被外部刪除NFS 刪除競態)時,重試會重建目錄
path = None
def _save_fig_once():
nonlocal path
_retry_robust(os.makedirs, output_folder, exist_ok=True)
# 檔名只用 levelvolume id 已在資料夾名裡,不重複)
path = save_with_unique_name(output_folder, level, way,
diameter_l, length_l, diameter_r, length_r,
swarm_size, max_iter)
fig.savefig(path, dpi=200, bbox_inches="tight")
_retry_robust(_save_fig_once)
print("[Saved figure]", path)
plt.close(fig)
def eval_overlap_from_position( def eval_overlap_from_position(
pos, pos,
@ -701,5 +89,3 @@ def eval_overlap_from_position(
overlap = compute_overlap_ratio_from_cylinder_mask(cyl_mask, spine_tensor) overlap = compute_overlap_ratio_from_cylinder_mask(cyl_mask, spine_tensor)
return overlap, d, L return overlap, d, L

View file

@ -177,7 +177,8 @@ def _write_rotated_level(vol_dir, level, smd_path, mask_path, roi_path):
- _roi.nii.gz - _roi.nii.gz
- _cortical.nii.gz 旋轉 CT 以骨頭 mask median HU 為門檻 - _cortical.nii.gz 旋轉 CT 以骨頭 mask median HU 為門檻
取代舊的未旋轉 _cortical定義相同 取代舊的未旋轉 _cortical定義相同
再畫rotated平面圖並在旋轉體上做 VBODY / 棘突分割存 label map 再畫rotated平面圖bone + 平面 + VBODY [] / 棘突 [] 著色
並在旋轉體上做 VBODY / 棘突分割存 label map
1=VBODY2=棘突3=other bone0=background 1=VBODY2=棘突3=other bone0=background
平面參數經 R 剛性旋轉並換元到輸出 grid 的局部座標 平面參數經 R 剛性旋轉並換元到輸出 grid 的局部座標
@ -316,13 +317,14 @@ def _write_rotated_level(vol_dir, level, smd_path, mask_path, roi_path):
else: else:
logger.warning(f'[rotated] {volume_id} {level}: 無旋轉 CT / mask跳過 _cortical') logger.warning(f'[rotated] {volume_id} {level}: 無旋轉 CT / mask跳過 _cortical')
# 用旋轉後的平面畫圖rotated 版 planes皮質著色由未旋轉 CT + mask # 用旋轉後的平面畫圖rotated 版 planes並畫出 VBODY / 棘突著色
#VBODY=金、棘突=紫,與 label map 對應);皮質著色由未旋轉 CT + mask
# 現算(未旋轉 _cortical 不再存檔) # 現算(未旋轉 _cortical 不再存檔)
p_fig = os.path.join(rotated_dir, f'{level}_planes.png') p_fig = os.path.join(rotated_dir, f'{level}_planes.png')
if mask_path is not None: if mask_path is not None:
fig_cortical = _cortical_from_roi(roi_arr, bin_arr) fig_cortical = _cortical_from_roi(roi_arr, bin_arr)
fig = render_bone_figure(volume_id, level, mask_path, fig_cortical, fig = render_bone_figure(volume_id, level, mask_path, fig_cortical,
planes_only=True, rotation=(R, c_xyz), output_path=p_fig) planes_only=False, rotation=(R, c_xyz), output_path=p_fig)
if fig is not None: if fig is not None:
logger.info(f'[rotated] saved {fig}') logger.info(f'[rotated] saved {fig}')
else: else:
@ -357,7 +359,7 @@ def make_lumbar_post_process():
"""每個 volume 處理完後,對其 lumbar level """每個 volume 處理完後,對其 lumbar level
1) 骨頭 + 方向平面不畫螺絲不做棘突 / 椎體分割-> <volume_dir>/lumbar/ 1) 骨頭 + 方向平面不畫螺絲不做棘突 / 椎體分割-> <volume_dir>/lumbar/
2) 計算對齊旋轉存旋轉後的 smd_resampled / binary_sdf / binary_nn / roi 2) 計算對齊旋轉存旋轉後的 smd_resampled / binary_sdf / binary_nn / roi
+ cortical + 旋轉平面圖 + label map -> <volume_dir>/rotated/ + cortical + 旋轉平面圖 VBODY / 棘突著色+ label map -> <volume_dir>/rotated/
post_process process_dataset 呼叫(volume_dir, processed_labels) post_process process_dataset 呼叫(volume_dir, processed_labels)
processed_labels 為該 volume 實際存在的 label idint對照 LABEL_MAP processed_labels 為該 volume 實際存在的 label idint對照 LABEL_MAP
@ -397,7 +399,7 @@ def make_lumbar_post_process():
logger.info(f'[lumbar] saved {path}') logger.info(f'[lumbar] saved {path}')
# 2) 旋轉對齊rotated/ 的 smd_resampled + binary_sdf + binary_nn # 2) 旋轉對齊rotated/ 的 smd_resampled + binary_sdf + binary_nn
# + roi + cortical + planes 圖 + label # + roi + cortical + planes 圖(含 VBODY / 棘突著色)+ label
_write_rotated_level(vol_dir, level, smd_res_path, mask_path, roi_path) _write_rotated_level(vol_dir, level, smd_res_path, mask_path, roi_path)
return _post_process return _post_process
@ -407,7 +409,12 @@ def main():
parser = argparse.ArgumentParser(description='Preprocess CT spine dataset.') parser = argparse.ArgumentParser(description='Preprocess CT spine dataset.')
parser.add_argument('--max-images', type=int, default=None, dest='max_images', parser.add_argument('--max-images', type=int, default=None, dest='max_images',
help='Process at most this number of images per dataset (default: all).') help='Process at most this number of images per dataset (default: all).')
parser.add_argument('--output-dir', type=str, default=None, dest='output_dir',
help='Override the default output dir (e.g. repair run on an '
'older generation). Default: the module-level output_dir.')
args = parser.parse_args() args = parser.parse_args()
# local alias避免 rebind module-level output_dir 造成 UnboundLocalError
out_dir = args.output_dir if args.output_dir is not None else output_dir
# log 檔console 與檔案同時輸出) # log 檔console 與檔案同時輸出)
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
@ -422,6 +429,7 @@ def main():
logger.info(f'Log file: {log_path}') logger.info(f'Log file: {log_path}')
logger.info(f'Command: {sys.executable} {" ".join(sys.argv)}') logger.info(f'Command: {sys.executable} {" ".join(sys.argv)}')
logger.info(f'Working directory: {os.getcwd()}') logger.info(f'Working directory: {os.getcwd()}')
logger.info(f'Output dir: {out_dir}')
# metadata db跳過判定z spacing / lumbar 層數)命中時免讀影像 / label 檔 # metadata db跳過判定z spacing / lumbar 層數)命中時免讀影像 / label 檔
metadata_db = ImageMetadataDB() metadata_db = ImageMetadataDB()
@ -432,7 +440,7 @@ def main():
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, max_images=args.max_images, process_dataset(data_dir, label_dir, out_dir, max_images=args.max_images,
post_process=post_process, max_z_spacing=MAX_Z_SPACING_MM, post_process=post_process, max_z_spacing=MAX_Z_SPACING_MM,
allowed_levels=LUMBAR_LEVELS, min_levels=MIN_LUMBAR_LEVELS, allowed_levels=LUMBAR_LEVELS, min_levels=MIN_LUMBAR_LEVELS,
metadata_cache=metadata_db) metadata_cache=metadata_db)

128
xfr_reprocess_ap.py Normal file
View file

@ -0,0 +1,128 @@
#!/home/xfr/.conda/envs/cbt/bin/python
"""掃 standardize 輸出目錄中「前後AP方向翻轉」的個案prone 伏位掃描,
前側落在 y 小側colon 0003供重跑修正用
判定對每個 volume L1~L6 輸出遮罩 _binary_sdf _binary_nn
_binary個別跑 orientation.anterior_y_side中線帶椎管兩側質量比較
見該函式 docstring level 票決
flip : y_min > y_max 前後翻轉需重跑
ok : y_max > y_min 方向正常
mixed : 平手需人工確認
unknown : 全部無法判定無明顯前後質量差如鏡稱面異常案例
Usage:
python xfr_reprocess_ap.py <output_dir> # 只回報
python xfr_reprocess_ap.py <output_dir> --fix # 另刪 flip volume 的輸出
# 資料夾 + progress.json
# 條目,之後重跑
# xfr_preprocess.py 即可
"""
import argparse
import json
import os
import shutil
import sys
import time
import SimpleITK as sitk
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from imaging.orientation import anterior_y_side
LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5', 'L6')
MASK_SUFFIXES = ('_binary_sdf.nii.gz', '_binary_nn.nii.gz', '_binary.nii.gz')
def volume_decision(vol_dir):
"""回傳 (decision, per_level dict)。decision ∈ flip/ok/mixed/unknown/nomask。"""
per = {}
for lvl in LEVELS:
for suf in MASK_SUFFIXES:
p = os.path.join(vol_dir, f'{lvl}{suf}')
if os.path.exists(p):
m = sitk.GetArrayFromImage(sitk.ReadImage(p, sitk.sitkUInt8))
per[lvl] = anterior_y_side(m)
break
if not per:
return 'nomask', per
votes = [v for v in per.values() if v is not None]
if not votes:
return 'unknown', per
n_min = votes.count('y_min')
n_max = votes.count('y_max')
if n_min > n_max:
return 'flip', per
if n_max > n_min:
return 'ok', per
return 'mixed', per
def main():
parser = argparse.ArgumentParser(
description='Find AP-flipped (prone) volumes in a standardized output dir.')
parser.add_argument('output_dir')
parser.add_argument('--fix', action='store_true',
help='Also delete flipped volumes\' output dirs and '
'their progress.json entries')
args = parser.parse_args()
outdir = args.output_dir
if not os.path.isdir(outdir):
print(f'not a directory: {outdir}')
return
vols = sorted(d for d in os.listdir(outdir)
if os.path.isdir(os.path.join(outdir, d)))
flip, mixed, unknown, ok, nomask = [], [], [], [], []
t0 = time.time()
for i, vol in enumerate(vols, 1):
dec, per = volume_decision(os.path.join(outdir, vol))
tag = {'flip': 'FLIP', 'ok': 'ok ', 'mixed': 'MIXED',
'unknown': '?!?', 'nomask': '- '}[dec]
detail = ' '.join(f'{k}={v}' for k, v in per.items())
print(f'[{i}/{len(vols)}] {tag} {vol} {detail}')
{'flip': flip, 'mixed': mixed, 'unknown': unknown,
'ok': ok, 'nomask': nomask}[dec].append(vol)
if (i % 25) == 0:
print(f' ... {i}/{len(vols)} ({(time.time()-t0)/60:.1f} min)', flush=True)
print(f'\n=== Summary: {len(vols)} volumes ===')
print(f' ok (normal) : {len(ok)}')
print(f' FLIP (AP-flipped) : {len(flip)}')
for v in flip:
print(f' - {v}')
print(f' mixed (need check) : {len(mixed)}')
for v in mixed:
print(f' - {v}')
print(f' unknown (no vote) : {len(unknown)}')
for v in unknown:
print(f' - {v}')
print(f' no level mask : {len(nomask)}')
if args.fix and flip:
# 1) progress.json刪掉 flip 的條目(備份)
prog_path = os.path.join(outdir, 'progress.json')
if os.path.exists(prog_path):
with open(prog_path) as f:
prog = json.load(f)
removed = [v for v in flip if prog.pop(v, None) is not None]
bak = f'{prog_path}.apfix-{time.strftime("%Y%m%d_%H%M%S")}'
shutil.copyfile(prog_path, bak)
with open(prog_path, 'w') as f:
json.dump(prog, f, indent=2)
print(f'\nprogress.json: removed {len(removed)} entr(y/ies) '
f'[{", ".join(v.split(".")[-1] or v for v in removed)}]; '
f'backup {bak}')
# 2) 刪輸出資料夾
for v in flip:
shutil.rmtree(os.path.join(outdir, v))
print(f'removed {os.path.join(outdir, v)}')
print('\nNext: rerun `python xfr_preprocess.py` — only the removed '
'volumes will be reprocessed (with the AP flip applied).')
elif not args.fix and flip:
print('\nRerun with --fix to delete the flipped outputs and progress '
'entries, then run `python xfr_preprocess.py`.')
if __name__ == '__main__':
main()