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

View file

@ -17,7 +17,7 @@ def cl_score_torch_xfr(
漸進式評分優先確保找到骨頭再改善細節
"""
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 分
# 100 分項由純 cortical 擴展到 corticalVBODYcortical voxel 分數不變),
# 其中落在 VBODY 的 voxel 每個再加 10 分
@ -46,14 +46,14 @@ def cl_score_torch_xfr(
# if in_bone == 0:
# return float(not_in_bone*200)
score += 20 * in_bone # 10 實在太低
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 -= 1000 * max(0, null_vox2-10)
score -= 1000 * not_in_bone
score -= 2000 * null_vox2
score += 10 * in_bone # 10 實在太低
score += 100 * overlap # in cortical
score += 100 * in_corti_vb # (cortical + VBODY) 每 voxel 再加
score += 50 * in_vbody # VBODY 每 voxel 再加
# score -= 1000 * not_in_bone
score -= 1000 * max(0, not_in_bone-10)
# score -= 2000 * null_vox2
score -= 2000 * max(0, null_vox2-10)
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 全寬)
2) 前後方向平面內兩軸 (u, v) |y| 分量大者
正規化成 +AP = 後側本資料系 y 往前遞增後側 = y 小側
3) 中線帶的 AP 分佈呈兩大叢椎體在前椎弓/棘突在後
以兩叢間的 AP 谷底為界AP >= 谷底 的中線帶 voxel = 棘突含中線椎弓
無明顯谷底如骨橋fallback 取中線帶後側 15%
3) 中線帶的 AP 分佈呈兩大叢椎體在前椎弓/棘突在後中間椎管
空隙連續 <5% 峰值的安靜 bin最長空隙定位= 椎管
棘突閾值取空隙後側端AP >= 空隙後側端 = 後側叢含中線椎弓
回傳的椎體閾值取空隙體側邊谷 segment_vertebral_body 使用
兩閾值被椎管隔開椎體後側緣 fringe 不會被誤判成棘突
無明顯空隙如骨橋fallback 取中線帶後側 15%
4) 中線帶側緣補回_expand_spinous_runsexpand_cap帶由鏡稱面定義
棘突楔若略偏中線側緣薄條會留在帶外成為 other bone每條 (y, z)
線把棘突 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()))
hi = int(np.ceil(aps.max()))
th = None
th_sp = None
mode = 'fallback'
if hi - lo >= 10:
hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1]
peak = hist.max()
best_i, best_score = None, -1.0
for i in range(len(hist)):
if hist[i] >= 0.05 * peak:
# 收集連續安靜 bin<5% 峰值)的「空隙」,要求兩側質量都夠
# >= min_mass_frac * total取最長者 = 椎管。
# (舊式單 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
if csum[i] < min_mass_frac * total or (total - csum[i + 1]) < min_mass_frac * total:
continue
score = min(csum[i], total - csum[i + 1])
if score > best_score:
best_score, best_i = score, i
if best_i is not None:
post_frac = (total - csum[best_i + 1]) / total
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_mass_frac * total and right >= min_mass_frac * 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:
# 谷底後側叢只剩小殘片(<5% 中線帶質量)=棘突幾乎全除
# 空隙後側叢只剩小殘片(<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[i0] + edges[i0 + 1])) # 回傳值:體側邊谷,椎體 AP 切點
th_sp = float(edges[i1 + 1]) # 空隙後側端:棘突由此開始
mode = 'gap'
if th is None:
th = float(np.quantile(aps, 0.85))
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 = _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
@ -429,6 +451,98 @@ def _ap_axis(sym):
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):
"""終板下骨體 AP 分佈的平滑谷底最深相對谷底sm[i] 對鄰近峰的最小比值),
要求兩側各有 >= 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]))
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) 切出椎體前側中央主體塊
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
c) 否則終板下整體 AP 分佈的平滑谷底_full_ap_valley
處理中線骨橋等中線搜尋 fallback 的情形
d) 最後 fallbackAP 分佈 55 百分位可能切進椎體內會打 WARNING
椎體 = AP < 切點切點之前側且終板下側的 bone
d) 最後 fallbackAP 分佈 55 百分位可能切進椎體內會打 WARNING
椎體 = AP < 切點切點之前側且終板下側的 bone
3) 側向包絡lateral clip椎體是終板下的中央塊橫突及弓根
側緣向鏡稱面法線方向延伸其前緣恰好跨過 AP 切點椎體後側
兩角處只靠兩平面會把橫突前段算進椎體 AP 切點較遠
AP 排除靠切點後側 sliver_frac 的帶帶內正是橫突前緣
量出椎體自身的側向鏡稱面寬度再把候選裁到該寬度
+ margin橫突遠超出椎體寬多達數厘米被去除椎體本體
含最寬處因其落在窗內或 margin 範圍保留
回傳 (vb_mask (z,y,x) bool, ap_thresh, info dict)
資料不足或上終板面缺位時 vb_mask = Noneinfo['mode'] 說明原因
info['lat_clip'] 記錄實際施加的側向切點未施加時為 None
"""
m = np.asarray(mask_zyx) > 0
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:
th = float(np.quantile(aps, 0.55))
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():
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)
info.update(n_vb=int(sel.sum()), ap_thresh=th, mode=mode, lat_clip=lat_clip)
return vb_mask, th, info
def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=4.0,

View file

@ -1,4 +1,5 @@
import os
import numpy as np
import SimpleITK as sitk
from imaging.resample import resample_img
from imaging.affine import standardize_affine
@ -7,6 +8,18 @@ import json
import glob
from config.constant import LABEL_MAP
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_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)
name = file_name.replace(".nii.gz", "")

View file

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

View file

@ -1,3 +1,4 @@
import csv
import os
from datetime import datetime
@ -11,9 +12,10 @@ 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
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 get_unique_filepath, retry_robust, save_with_unique_name
# 體積吸收渲染Beer-Lambert與 res_plot_3d 相同:
@ -235,38 +237,105 @@ def _rotate_plane_params(plane, R, c_xyz):
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,
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 圖(不畫螺絲)。
base_folder="/mnt/1248/open2/cyrou/Output",
spacing=(0.5, 0.5, 0.5), way="CBT",
planes_only=False, output_path=None, rotation=None,
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
內容皮質 vs 鬆質吸收骨椎體(gold)棘突(purple)
中矢狀鏡稱面(orange)上終板面(green)無圓柱/中心線
四視角預設 / axial俯視 XY/ 冠狀後視/ 矢狀
內容皮質 vs 鬆質吸收骨椎體(gold)棘突(purple獨立層覆蓋椎體)
中矢狀鏡稱面(orange)上終板面(green)螺絲模式另畫中心線()
+ 圓柱L darkcyan / R blueo 層粉
繪製採固定分層不依深度排序基底骨 < VBODY < 棘突 < 終板 < 鏡稱面 < 螺絲
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 / 缺檔時全部視為鬆質骨
骨骼輸入
binary_path 骨頭遮罩path (nifti) (z,y,x) ndarray / torch tensor
cortical_path 皮質遮罩同型path / ndarray / tensorNone / 缺檔
時全部視為鬆質骨
volume_id/level 可為 None由路徑反推/{vol}/rotated/{level}_*.nii.gz
rotated 的上一層 = vol
image2_path 未給定時 = binary_pathpath 情形TPS 模式用其算 2D
參考 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
"""
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:
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)
cortical = _mask_to_array(cortical_path)
if cortical is None:
cortical = np.zeros_like(spine)
image_shape = spine.shape
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)
@ -279,26 +348,57 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
sym = best_symmetry_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
vb_corti = vb_trab = None
sp_info = {}
vb_info = {}
else:
# 棘突:鏡稱面中線帶(|s|<=w且在 AP 谷底之後側;棘突缺如
#(先前 laminectomy / 棘突切除)時 sp_mask=None不標示
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_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:
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():
vb_corti = vb_mask[z_corti, y_corti, x_corti]
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:
vb_corti = vb_trab = None
if screw_mode:
print(f"[VBODY] skipped: {vb_info['mode']}")
# ---- 骨頭點雲(體積吸收)----
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_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:
vb_flag = np.concatenate([vb_corti, vb_trab])
vb_flag = np.concatenate([vb_corti, vb_trab]).astype(bool)
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])
sp_flag = np.concatenate([sp_corti, sp_trab]).astype(bool)
if BONE_SUBSAMPLE > 1:
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:
@ -340,6 +447,146 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
if symp is not None:
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"]
_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))
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:
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"))
legend_handles.append(Line2D([], [], marker="o", ls="", ms=6, color="purple", label="SpinousProcess"))
def _fill_ax(ax):
# 固定分層(關 depth zorder否則半透明骨頭會被重繪到螺絲上方
# 基底骨(5) < VBODY gold(6) < 棘突 purple(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 = ax.scatter(x_base, y_base, z_base, c=rgba_base, s=size_base, 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 x_vb.size:
sc_vb = ax.scatter(x_vb, y_vb, z_vb, c=to_rgba("gold", 0.95),
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:
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)
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")
_fill_ax(ax1)
@ -407,7 +673,8 @@ def render_bone_figure(volume_id, level, binary_path, cortical_path,
ax2.legend(handles=legend_handles)
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)
ax3.set_xlabel("X-axis"); ax3.set_ylabel("Y-axis"); ax3.set_zlabel("Z-axis")
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)
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)
if screw_mode:
fig.text(0.5, 0.98, f"{label_str} Best Position", 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'}")
d_l = f"{diameter_l} mm, {length_l} mm" if diameter_l is not None else ''
d_r = f"{diameter_r} mm, {length_r} mm" if diameter_r is not None else ''
t_s = f"Total time = {total_time:.2f} s" if total_time is not None else ''
fig.text(
0.5, 0.44,
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:
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)
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()
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:
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:
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")
path = get_unique_filepath(
os.path.join(output_folder, f"{volume_id} {level}_{way}.png"))
retry_robust(os.makedirs, os.path.dirname(path) or ".", exist_ok=True)
retry_robust(fig.savefig, path, dpi=200, bbox_inches="tight")
print("[Saved figure]", path)
plt.close(fig)
return path

View file

@ -1,47 +1,14 @@
import torch
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.intersection import center_line_intersections_torch
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
from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values
from core.scoring import compute_overlap_ratio_from_cylinder_mask
# 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):
"""
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()
y_limits = ax.get_ylim3d()
@ -59,610 +26,31 @@ def set_axes_equal_3d(ax):
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 res_plt_2_torch(
spine_tensor: torch.Tensor,
cortical_tensor: torch.Tensor,
image_shape: tuple[int, int, int],
image2_path: str,
base_folder: str,
label_str: str,
diameter_l: float,
length_l: float,
diameter_r: float,
length_r: float,
best_position_l: list[float],
best_position_r: list[float],
swarm_size: int,
max_iter: int,
total_time: float,
spacing: list[float],
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
def res_plt_2_torch(spine_tensor, cortical_tensor, image_shape, image2_path,
base_folder, label_str, diameter_l, length_l,
diameter_r, length_r, best_position_l, best_position_r,
swarm_size, max_iter, total_time, spacing, CBT, device,
grid):
"""相容入口(舊簽名):實作已合併進 res_bone_figure.render_bone_figure。
label_str / image_shape 不再使用volume / level image2_path 反推"""
from visualization.res_bone_figure import render_bone_figure
return render_bone_figure(
None, None, spine_tensor, cortical_tensor, base_folder,
spacing=spacing, way='CBT' if CBT else 'TPS',
best_position_l=best_position_l, best_position_r=best_position_r,
diameter_l=diameter_l, length_l=length_l,
diameter_r=diameter_r, length_r=length_r,
image2_path=image2_path, device=device, grid=grid,
swarm_size=swarm_size, max_iter=max_iter, total_time=total_time,
)
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(
pos,
@ -700,6 +88,4 @@ def eval_overlap_from_position(
)
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
- _cortical.nii.gz 旋轉 CT 以骨頭 mask median HU 為門檻
取代舊的未旋轉 _cortical定義相同
再畫rotated平面圖並在旋轉體上做 VBODY / 棘突分割存 label map
再畫rotated平面圖bone + 平面 + VBODY [] / 棘突 [] 著色
並在旋轉體上做 VBODY / 棘突分割存 label map
1=VBODY2=棘突3=other bone0=background
平面參數經 R 剛性旋轉並換元到輸出 grid 的局部座標
@ -316,13 +317,14 @@ def _write_rotated_level(vol_dir, level, smd_path, mask_path, roi_path):
else:
logger.warning(f'[rotated] {volume_id} {level}: 無旋轉 CT / mask跳過 _cortical')
# 用旋轉後的平面畫圖rotated 版 planes皮質著色由未旋轉 CT + mask
# 用旋轉後的平面畫圖rotated 版 planes並畫出 VBODY / 棘突著色
#VBODY=金、棘突=紫,與 label map 對應);皮質著色由未旋轉 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)
planes_only=False, rotation=(R, c_xyz), output_path=p_fig)
if fig is not None:
logger.info(f'[rotated] saved {fig}')
else:
@ -357,7 +359,7 @@ 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/
+ cortical + 旋轉平面圖 VBODY / 棘突著色+ label map -> <volume_dir>/rotated/
post_process process_dataset 呼叫(volume_dir, processed_labels)
processed_labels 為該 volume 實際存在的 label idint對照 LABEL_MAP
@ -397,7 +399,7 @@ def make_lumbar_post_process():
logger.info(f'[lumbar] saved {path}')
# 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)
return _post_process
@ -407,7 +409,12 @@ 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).')
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()
# 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 與檔案同時輸出)
os.makedirs(LOG_DIR, exist_ok=True)
@ -422,6 +429,7 @@ def main():
logger.info(f'Log file: {log_path}')
logger.info(f'Command: {sys.executable} {" ".join(sys.argv)}')
logger.info(f'Working directory: {os.getcwd()}')
logger.info(f'Output dir: {out_dir}')
# metadata db跳過判定z spacing / lumbar 層數)命中時免讀影像 / label 檔
metadata_db = ImageMetadataDB()
@ -432,7 +440,7 @@ def main():
for key, value in label_map.items():
data_dir = os.path.join(data_root, key)
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,
allowed_levels=LUMBAR_LEVELS, min_levels=MIN_LUMBAR_LEVELS,
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()