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:
parent
523ec7ee16
commit
2ae08ac2cd
10 changed files with 976 additions and 812 deletions
|
|
@ -24,7 +24,7 @@ ALLOWED_DIAMETERS = [
|
|||
# 7.5,
|
||||
]
|
||||
ALLOWED_LENGTHS = [
|
||||
25,
|
||||
# 25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
|
||||
# 上終板面與椎體都在「完整 mask(SP 移除前)」上計算:
|
||||
# - 終板面由前側頂面擬合,SP 移除不改變平面;
|
||||
# - 椎體與 res_plt_2_torch 的 gold 顯示完全同 input / 同參數,
|
||||
# - 椎體與 render_bone_figure 的 gold 顯示完全同 input / 同參數,
|
||||
# 確保顯示出來的椎體就是 loss 裡 VBODY 獎勵的區域。
|
||||
# 棘突缺如(laminectomy,mode='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 lock(O_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
|
||||
|
|
@ -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 擴展到 cortical∪VBODY;cortical 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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_runs,expand_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 的單層
|
||||
# mask),best_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) 最後 fallback:AP 分佈 55 百分位(可能切進椎體內,會打 WARNING)。
|
||||
椎體 = AP < 切點(切點之前側)且終板下側的 bone。
|
||||
d) 最後 fallback:AP 分佈 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 = None(info['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):逐 z(endplate 法線層)在離 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,
|
||||
|
|
|
|||
|
|
@ -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_affine(nibabel 端)會在輸出 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 db(ap_flip),重跑免重算。
|
||||
ap_flip = (meta or {}).get("ap_flip")
|
||||
if ap_flip is None:
|
||||
# standardize_affine 是否會翻轉 y(nibabel 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", "")
|
||||
|
|
|
|||
|
|
@ -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... 避免覆蓋
|
||||
|
|
|
|||
|
|
@ -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) bool;None / 缺檔 / 空 -> 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 blue,o 層粉)。
|
||||
繪製採固定分層(不依深度排序):基底骨 < 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 / tensor);None / 缺檔
|
||||
時全部視為鬆質骨
|
||||
volume_id/level 可為 None:由路徑反推(…/{vol}/rotated/{level}_*.nii.gz,
|
||||
rotated 的上一層 = vol)
|
||||
image2_path 未給定時 = binary_path(path 情形);TPS 模式用其算 2D
|
||||
參考 az/alt(Azimuth/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 flags(corti+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]
|
||||
|
||||
# ---- 螺絲:圓柱 + 中心線 + loss(lazy 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('螺絲模式需要 device(torch 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 後 o(L_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
|
||||
|
|
@ -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 相对鏡稱面(法線 s,theta_v = atan2(sy, sx)):
|
||||
# Azimuth_Lateral = az - theta_v - 90 (面內 AP 軸起的帶號發散角,+ = L 側往外,− = R 側)
|
||||
# Altitude 相對上終板面(法線 e,朝上):
|
||||
# Altitude_Cephalad_Endplate = 90 - (d 與 e 的夾角)
|
||||
sym_n = np.asarray(sym['normal'], dtype=float)
|
||||
theta_v = float(np.degrees(np.arctan2(sym_n[1], sym_n[0])))
|
||||
if symp is not None:
|
||||
e_n = np.asarray(symp['normal'], dtype=float)
|
||||
e_n = e_n / np.linalg.norm(e_n)
|
||||
tau_y = float(np.degrees(np.arctan2(e_n[1], e_n[2])))
|
||||
tau_x = float(np.degrees(np.arctan2(e_n[0], e_n[2])))
|
||||
else:
|
||||
e_n = None
|
||||
tau_y = float('nan')
|
||||
tau_x = float('nan')
|
||||
|
||||
def _rel_angles(az_deg, alt_deg):
|
||||
az_r = np.radians(az_deg)
|
||||
alt_r = np.radians(alt_deg)
|
||||
d_v = np.array([np.cos(az_r) * np.sin(alt_r),
|
||||
np.sin(az_r) * np.sin(alt_r),
|
||||
np.cos(alt_r)])
|
||||
az_lateral = az_deg - theta_v - 90.0
|
||||
if e_n is not None:
|
||||
ang_norm = float(np.degrees(np.arccos(np.clip(d_v @ e_n, -1.0, 1.0))))
|
||||
alt_cep = 90.0 - ang_norm
|
||||
else:
|
||||
alt_cep = float('nan')
|
||||
return az_lateral, alt_cep
|
||||
|
||||
azlat_l, acep_l = _rel_angles(best_position_l[3], best_position_l[4])
|
||||
azlat_r, acep_r = _rel_angles(best_position_r[3], best_position_r[4])
|
||||
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
# 旋轉後影像存在 <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 為 nan),Azimuth/Altitude 直接顯示
|
||||
# 最佳化出的原始角(=CSV 的 Raw_Azimuth / Raw_Altitude);TPS 沿用 2D 參考之相對角。
|
||||
# 另補上相對骨骼的角度(與 CSV 同參數):
|
||||
# Azimuth_Lateral = 螺絲在鏡稱面內相對 AP 軸的發散角(+ = L 側往外,− = R 側)
|
||||
# Altitude_Endplate = 螺絲相對上終板面的仰角
|
||||
# 終板面擬合失敗(nan)時該段自動略過。
|
||||
def _fig_angle_segs(az, alt, azlat, acep):
|
||||
segs = [f'Azimuth = {az:.2f}', f'Altitude = {alt:.2f}']
|
||||
if np.isfinite(azlat):
|
||||
segs.append(f'Azimuth_Lateral = {azlat:.2f}')
|
||||
if np.isfinite(acep):
|
||||
segs.append(f'Altitude_Endplate = {acep:.2f}')
|
||||
return ', '.join(segs)
|
||||
|
||||
if CBT:
|
||||
_ang_l = _fig_angle_segs(float(best_position_l[3]), float(best_position_l[4]), azlat_l, acep_l)
|
||||
_ang_r = _fig_angle_segs(float(best_position_r[3]), float(best_position_r[4]), azlat_r, acep_r)
|
||||
else:
|
||||
_ang_l = _fig_angle_segs(user_azimuth_l, user_altitude_l, azlat_l, acep_l)
|
||||
_ang_r = _fig_angle_segs(user_azimuth_r, user_altitude_r, azlat_r, acep_r)
|
||||
|
||||
fig.text(
|
||||
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)
|
||||
# 檔名只用 level(volume id 已在資料夾名裡,不重複)
|
||||
path = save_with_unique_name(output_folder, level, way,
|
||||
diameter_l, length_l, diameter_r, length_r,
|
||||
swarm_size, max_iter)
|
||||
fig.savefig(path, dpi=200, bbox_inches="tight")
|
||||
|
||||
_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
|
||||
|
|
@ -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=VBODY、2=棘突、3=other bone、0=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 id(int),對照 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
128
xfr_reprocess_ap.py
Normal 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()
|
||||
Loading…
Reference in a new issue