CBT_project/imaging/orientation.py
Xiao Furen 2ae08ac2cd 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.
2026-09-07 18:46:06 +08:00

1137 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import numpy as np
import SimpleITK as sitk
from scipy.ndimage import center_of_mass, rotate, distance_transform_edt, gaussian_filter
import matplotlib.pyplot as plt
# 鏡稱面法線的左右LR=x 軸)分量大下。正常左右鏡稱面法線幾乎沿 x
# 若 |a| < 0.7代表搜尋掉進前後coronal面局部極大椎體是厚實塊狀
# 易自鏡射拿高分0019 L3/L4以該平面定義的「中線帶」變成斜切
# 板層,其後側中線缺如判定不可信 → diagnose 回 bad_mirror_plane、
# 不判 no_spinous避免假 [NO-SP] 誤改椎體切分)。
MIRROR_MIN_LR = 0.7
def _best_vertical_split(mask2d):
"""
binary 2D 陣列(最後一軸 = x找讓 mask 與其鏡射重疊最大的垂直線 x = t。
c[s] = Σ_u A[u]·A[s-u] 是每列自卷積;批次 FFT 一次算出所有 s。
回傳 (score, s_best)s = 2·tt 可能為半整數)。
"""
n = mask2d.shape[-1]
flat = np.asarray(mask2d).reshape(-1, n)
rows = flat[flat.max(axis=1) > 0]
if rows.size == 0:
return 0.0, 0
L = 1 << (2 * n - 1).bit_length()
padded = np.zeros((rows.shape[0], L), dtype=np.float32)
padded[:, :n] = rows.astype(np.float32)
F = np.fft.rfft(padded, axis=1)
conv = np.fft.irfft(F * F, axis=1)[:, :2 * n - 1]
score = np.clip(conv.sum(axis=0), 0, None)
s_best = int(np.argmax(score))
return float(score[s_best]), s_best
def best_symmetry_axis_angle(proj, coarse_step=10.0, fine_step=1.0):
"""
2D binary 投影axis0=row y, axis1=col x的左右鏡稱軸搜尋
將影像旋轉 θ 後鏡稱軸變成垂直線col = const用 _best_vertical_split 評分;
粗搜 0..180°coarse_step再在 winner 附近細搜fine_step
回傳 (theta_deg, score, s_best)。
"""
base = (np.asarray(proj) > 0).astype(np.float32)
best_th, best, best_s = 0.0, -1.0, 0
for th in np.arange(0.0, 180.0, coarse_step):
sc, s = _best_vertical_split(rotate(base, th, reshape=False, order=0) > 0.5)
if sc > best:
best_th, best, best_s = float(th), sc, s
for th in np.arange(best_th - coarse_step, best_th + coarse_step, fine_step):
th2 = float(th) % 180.0
sc, s = _best_vertical_split(rotate(base, th2, reshape=False, order=0) > 0.5)
if sc > best:
best_th, best, best_s = th2, sc, s
return best_th, best, best_s
def _mirror_hit_count(X, Y, Z, n, d, m, sz, sy, sx):
"""每個骨 voxel 對平面 n·p=d 鏡射後四捨五入到最近 voxel回傳命中骨 voxel 的數量"""
nx, ny, nz = n
dist = X * nx + Y * ny + Z * nz - d
rx = (X - 2.0 * dist * nx).round().astype(np.int32)
ry = (Y - 2.0 * dist * ny).round().astype(np.int32)
rz = (Z - 2.0 * dist * nz).round().astype(np.int32)
ok = (rx >= 0) & (rx < sx) & (ry >= 0) & (ry < sy) & (rz >= 0) & (rz < sz)
if not ok.any():
return 0
return int(m[rz[ok], ry[ok], rx[ok]].sum())
def best_symmetry_plane(mask_zyx, phi_max=45.0, subsample=7,
coarse_step=7.5, fine_step=1.0):
"""
3D bone mask (z, y, x) 的最佳鏡稱面,一般平面方程 a·x + b·y + c·z = d
voxel index 座標;(a,b,c) 為單位法線,方向任意,不限制平行 YZ 面)。
以「每個 bone voxel 鏡射後四捨五入到最近 voxel 的命中率」為分數,
參數化 n = R_y(phi)·R_z(theta)·(1,0,0)
theta : 法線在 axial (x,y) 平面內的旋轉
phi : 法線出平面的傾斜,限制在 [-phi_max, +phi_max] 以確保仍切分左右
搜尋theta 由 2D axial 投影粗定位,再 3D 三段式(粗→細→微細)
(粗/細階段用等距子樣 voxel 加速)。
回傳 dict
plane : (a, b, c, d) -> a*x + b*y + c*z = d
normal : (a, b, c)
offset : d
theta_deg / phi_deg : 法線參數
ratio : 鏡射命中率0..1
u, v : 平面內兩個正交方向(供繪製用)
"""
m = np.asarray(mask_zyx) > 0
sz, sy, sx = m.shape
zz, yy, xx = np.nonzero(m)
if xx.size < 100:
n = np.array([1.0, 0.0, 0.0])
d = (sx - 1) / 2.0
return {'plane': (1.0, 0.0, 0.0, float(d)), 'normal': (1.0, 0.0, 0.0),
'offset': float(d), 'theta_deg': 0.0, 'phi_deg': 0.0,
'ratio': 1.0, 'u': (0.0, 1.0, 0.0), 'v': (0.0, 0.0, 1.0)}
xf = xx.astype(np.float32)
yf = yy.astype(np.float32)
zf = zz.astype(np.float32)
N = xf.size
c0 = np.array([(sx - 1) / 2.0, (sy - 1) / 2.0, (sz - 1) / 2.0])
Xs, Ys, Zs = xf[::subsample], yf[::subsample], zf[::subsample]
def n_of(theta_deg, phi_deg):
t = np.deg2rad(theta_deg)
p = np.deg2rad(phi_deg)
return np.array([np.cos(t) * np.cos(p), np.sin(t), -np.cos(t) * np.sin(p)])
# theta 初值2D axial 投影搜尋(垂直面情形)
proj = m.max(axis=0)
theta0 = best_symmetry_axis_angle(proj.astype(np.float32))[0] if proj.sum() >= 10 else 0.0
best = None
def consider(theta, phi, d, full=False):
nonlocal best
n = n_of(theta, phi)
X, Y, Z = (xf, yf, zf) if full else (Xs, Ys, Zs)
s = _mirror_hit_count(X, Y, Z, n, d, m, sz, sy, sx)
if best is None or s > best['score']:
best = {'score': s, 'theta': float(theta), 'phi': float(phi), 'd': float(d)}
# stage 1粗搜尋子樣
for th in np.arange(theta0 - coarse_step * 2, theta0 + coarse_step * 2 + 1e-9, coarse_step):
for ph in np.arange(-phi_max, phi_max + 1e-9, coarse_step):
n = n_of(th, ph)
d0 = float(n @ c0)
for dd in (-10.0, 0.0, 10.0):
consider(th, ph, d0 + dd)
# stage 2細搜尋子樣
for th in np.arange(best['theta'] - 2 * fine_step * 2, best['theta'] + 2 * fine_step * 2 + 1e-9, fine_step):
for ph in np.arange(best['phi'] - 2 * fine_step * 2, best['phi'] + 2 * fine_step * 2 + 1e-9, fine_step):
for dd in (-3.0, -1.0, 0.0, 1.0, 3.0):
consider(th, ph, best['d'] + dd)
# stage 3微細搜尋全分辨率
for th in np.arange(best['theta'] - fine_step, best['theta'] + fine_step + 1e-9, fine_step / 2):
for ph in np.arange(best['phi'] - fine_step, best['phi'] + fine_step + 1e-9, fine_step / 2):
for dd in (-1.0, -0.5, 0.0, 0.5, 1.0):
consider(th, ph, best['d'] + dd, full=True)
# stage 4d 微調(全分辨率)
b = best
for dd in np.arange(-1.0, 1.0 + 1e-9, 0.25):
consider(b['theta'], b['phi'], b['d'] + dd, full=True)
n = n_of(best['theta'], best['phi'])
theta = best['theta']
d = best['d']
if theta > 90.0 or theta < -90.0: # 正規化到 (-90, 90],法線翻轉時 d 取反
theta -= 180.0
n = -n
d = -d
ratio = _mirror_hit_count(xf, yf, zf, n, d, m, sz, sy, sx) / N
u = np.cross(n, [0.0, 0.0, 1.0])
if np.linalg.norm(u) < 0.1:
u = np.cross(n, [1.0, 0.0, 0.0])
u = u / np.linalg.norm(u)
v = np.cross(n, u)
return {
'plane': (float(n[0]), float(n[1]), float(n[2]), float(d)),
'normal': (float(n[0]), float(n[1]), float(n[2])),
'offset': float(d),
'theta_deg': float(theta),
'phi_deg': float(best['phi']),
'ratio': float(ratio),
'u': (float(u[0]), float(u[1]), float(u[2])),
'v': (float(v[0]), float(v[1]), float(v[2])),
}
def diagnose_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
deficit_min=4.0, rear_margin=3.0, rear3_max=20,
narrow_frac=0.4):
"""
診斷該椎體的棘突(中線後側構造)是否缺如
(先前手術如 laminectomy / 棘突切除所造成)。
棘突完整時,其尖端是全椎體最後側的骨構造且位於中線,因此:
deficit = 全骨最後側 AP - 中線帶最後側 AP ~= 0
且中線窄帶 |s| <= narrow_frac*w 在最後側骨 rear_margin 內有充足骨 voxel。
切除後,中線帶的後側止於殘留後側要素,最後側骨偏到側方的
殘餘結構(椎板/關節突)→ deficit 大 + 後側中線空洞。
判定no_spinous = deficit >= deficit_min 且 rear3 <= rear3_max。
回傳 dict
n_bone : 骨 voxel 總數
band_w : 中線帶半寬 wvoxel
deficit : 後側中線缺如量voxel0.5 mm/vox
rear3 : 最後側 3 voxel 內、中線窄帶的骨 voxel 數
rear6 : 最後側 6 voxel 內、中線窄帶的骨 voxel 數
top_off : axial 投影y,x最後側一行相對鏡稱中線的偏移voxel
no_spinous : True = 後側中線缺如(棘突已切除)
reason : 'ok' / 'no_spinous' / 'too_few_bone' / 'too_few_midline'
/ 'bad_mirror_plane'
"""
m = np.asarray(mask_zyx) > 0
zz, yy, xx = np.nonzero(m)
out = {'n_bone': int(zz.size), 'band_w': None, 'deficit': 0.0,
'rear3': None, 'rear6': None, 'top_off': None,
'no_spinous': False, 'reason': 'ok'}
if zz.size < 100:
out['reason'] = 'too_few_bone'
return out
a, b, c, d = sym['plane']
if abs(a) < MIRROR_MIN_LR:
# 鏡稱面非左右為主(前後 coronal 局部極大)→ 缺如判定不可信,
# 按 ok 回傳(寧可漏報,不可假切除)
out['reason'] = 'bad_mirror_plane'
return out
X = xx.astype(np.float64)
Y = yy.astype(np.float64)
Z = zz.astype(np.float64)
s = X * a + Y * b + Z * c - d
u_ap = _ap_axis(sym) # +AP = 後側y 小側)
ap = X * u_ap[0] + Y * u_ap[1] + Z * u_ap[2]
w = max(float(min_band), float(band_frac) * float(s.max() - s.min()))
mid = np.abs(s) <= w
aps = ap[mid]
out['band_w'] = float(w)
if aps.size < 50:
out['reason'] = 'too_few_midline'
return out
narrow = np.abs(s) <= w * float(narrow_frac)
ap_max_all = float(ap.max())
deficit = ap_max_all - float(aps.max())
rear3 = int(((ap >= ap_max_all - rear_margin) & narrow).sum())
rear6 = int(((ap >= ap_max_all - 2.0 * rear_margin) & narrow).sum())
out['deficit'] = float(deficit)
out['rear3'] = rear3
out['rear6'] = rear6
proj = m.max(axis=0) # (y, x)
ys_p, xs_p = np.where(proj > 0)
# 只在鏡稱面 x 分量主導時才算 top_off最後側一行對中線的 x 偏移);
# 脊椎在面內大角度旋轉時 |a| 小,(d-b*y-c*z)/a 會放大成無意義的巨值。
if ys_p.size and abs(a) >= 0.5 * max(abs(b), abs(c)):
y_min = int(ys_p.min())
zc = (m.shape[0] - 1) / 2.0
x_mid = (d - b * y_min - c * zc) / a
out['top_off'] = float(abs(xs_p[ys_p == y_min].mean() - x_mid))
if deficit >= float(deficit_min) and rear3 <= int(rear3_max):
out['no_spinous'] = True
out['reason'] = 'no_spinous'
return out
def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
min_mass_frac=0.05, expand_cap=5):
"""
以 best_symmetry_plane 的結果 sym 從 3D bone mask (z, y, x) 切出棘突。
棘突是中線後側構造,利用鏡稱面 a·x+b·y+c·z=d 定義:
1) 中線帶:骨 voxel 到平面的有號距離 |s| <= w
w = max(min_band, band_frac * s 全寬)
2) 前後方向:平面內兩軸 (u, v) 中 |y| 分量大者,
正規化成 +AP = 後側(本資料系 y 往前遞增,後側 = y 小側)
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。
先做後側中線缺如判定diagnose_spinous_processlaminectomy/棘突切除);
缺如、或谷底後側叢只剩 <5% 殘片時 mode='no_spinous'、sp_mask=None
由呼叫端(入口面 / 椎體)改用對應策略。
回傳 (sp_mask (z,y,x) bool, ap_thresh, info dict)
資料過少或棘突缺如時 sp_mask = Noneinfo['mode'] 說明原因)。
"""
m = np.asarray(mask_zyx) > 0
zz, yy, xx = np.nonzero(m)
info = {'n_bone': int(zz.size), 'n_sp': 0, 'ap_thresh': None,
'band_w': None, 'mode': 'empty'}
if zz.size < 50:
return None, None, info
a, b, c, d = sym['plane']
X = xx.astype(np.float64)
Y = yy.astype(np.float64)
Z = zz.astype(np.float64)
s = X * a + Y * b + Z * c - d
u = np.array(sym['u'])
v = np.array(sym['v'])
u_ap = u if abs(u[1]) >= abs(v[1]) else v
if u_ap[1] > 0:
u_ap = -u_ap # +AP = 後側y 小側)
w = max(float(min_band), float(band_frac) * float(s.max() - s.min()))
mid = np.abs(s) <= w
ap = X * u_ap[0] + Y * u_ap[1] + Z * u_ap[2]
aps = ap[mid]
info['band_w'] = float(w)
if aps.size < 50:
info['mode'] = 'too_few_midline'
return None, None, info
# 後側中線缺如laminectomy / 棘突切除):最後側骨偏到側方殘餘、
# 中線帶後側無質量,谷底搜尋沒有意義 → no_spinous
# 呼叫端y_indices 入口面 / segment_vertebral_body改用對應策略。
diag = diagnose_spinous_process(mask_zyx, sym, band_frac=band_frac, min_band=min_band)
info['deficit'] = diag['deficit']
info['rear3'] = diag['rear3']
info['rear6'] = diag['rear6']
if diag['no_spinous']:
info['mode'] = 'no_spinous'
info['top_off'] = diag['top_off']
return None, None, info
lo = int(np.floor(aps.min()))
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()
# 收集連續安靜 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
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% 中線帶質量)=棘突幾乎全除
# (部分切除殘餘):當作缺如,椎體切分不採用此閾值。
# 僅在鏡稱面左右為主時才算數(斜板層時 post_frac 不可信)
info['mode'] = 'no_spinous'
info['post_frac'] = float(post_frac)
return None, None, info
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)
# 棘突用空隙後側端 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, ap_thresh_sp=th_sp, mode=mode)
return sp_mask, th, info
def _expand_spinous_runs(sp_mask, bone, cap=5):
"""對每條 (y, z) 水平線x = 左右方向),把既有棘突 run 向左右兩側各延伸
至多 cap 個 bone voxel補回落在中線帶 |s|<=w 之外的棘突楔側緣薄條
(帶由鏡稱面定義,楔略偏中線時側緣會被漏成 other bone
遇到非 bone、既有棘突 voxel 或達 cap 即停;只延伸既有 run不新建
run該線無棘突時絕不標記。椎弓厚處最多少數 cap 個「棘突基部」
相鄰 bone 一併補入,屬棘突連續結構。"""
out = sp_mask.copy()
if not out.any():
return out
nz, ny, nx = out.shape
for y in range(ny):
sp_y = out[:, y, :]
if not sp_y.any():
continue
bone_y = bone[:, y, :]
for z in np.flatnonzero(sp_y.any(axis=1)):
idx = np.flatnonzero(sp_y[z])
runs = np.split(idx, np.flatnonzero(np.diff(idx) > 1) + 1)
for r in runs:
lo, hi = int(r[0]), int(r[-1])
x = lo - 1
added = 0
while x >= 0 and added < cap and bone_y[z, x] and not out[z, y, x]:
out[z, y, x] = True
x -= 1
added += 1
x = hi + 1
added = 0
while x < nx and added < cap and bone_y[z, x] and not out[z, y, x]:
out[z, y, x] = True
x += 1
added += 1
return out
def smooth_mask_sdf(mask_zyx, sigma=1.5):
"""對 3D boolean mask 做有號距離場SDF高斯平滑
把表面沿法線做近似平均曲率流mean curvature flow圓化凸出的
鋸齒/尖刺、填進凹陷的缺口,但不會均勻收縮整體形状(與直接對 mask
做高斯Blur再 thresh 不同——後者會把薄結構一起磨細、體積明顯縮小)。
這是消除「三线性旋轉 > 0.5 之後仍剩餘的 voxel 級鋸齒」最有效的方法。
參數:
mask_zyx : (z, y, x) boolean / 0-1 陣列
sigma : 平滑強度voxel。1.5 ≈ 表面積 -4%、體積 -0.6%
越大越圓,但薄結構(棘突尖)會被磨損越多。
回傳同 shape 的 boolean mask。
"""
m = np.asarray(mask_zyx) > 0
if not m.any() or m.all():
return m.copy()
d_in = distance_transform_edt(m)
d_out = distance_transform_edt(~m)
sdf = d_in - d_out
return gaussian_filter(sdf, sigma=float(sigma)) > 0.0
def shape_based_smooth(vol01, sigma=1.5):
"""Shape-based interpolationSBI平滑以 0..1 體積的 0.5 等值面為
物件形狀shape計算有號距離場SDF內正外負在距離域做高斯
插值interpolation再於 0 重新閾值,得到圓滑的 smooth mask。
與 smooth_mask_sdf 的差異邊界層0 < v < 1內三线性場值滿足
v ≈ 0.5 + dd = 以 voxel 為單位的有號距離),故直接以 v - 0.5 替換
EDT 量值,得到次體素精確的 SDF層外仍由 distance_transform_edt 給出
精確距離);表面梯度連續,不再是被 EDT 量化成 ±1 的硬階梯,平滑後
的邊界更貼合 soft boundary 的真實位置。
參數:
vol01 : (z, y, x) 0..1 浮點體積(例如旋轉後的 _binary_linear
sigma : 高斯平滑強度voxel意義與 smooth_mask_sdf 相同。
回傳:同 shape 的 boolean mask。
"""
v = np.clip(np.asarray(vol01, dtype=np.float32), 0.0, 1.0)
b = v > 0.5
if not b.any() or b.all():
return b.copy()
d_in = distance_transform_edt(b)
d_out = distance_transform_edt(~b)
sdf = d_in - d_out
band = (v > 0) & (v < 1)
sdf[band] = v[band] - 0.5
return gaussian_filter(sdf, sigma=float(sigma)) > 0.0
def _ap_axis(sym):
"""鏡稱面內的前後AP(u,v) 中 |y| 分量大者,正規化成 +AP = 後側y 小側)
(本資料系 y 往前遞增,與 segment_spinous_process 同慣例)。"""
u = np.array(sym['u'])
v = np.array(sym['v'])
u_ap = u if abs(u[1]) >= abs(v[1]) else v
if u_ap[1] > 0:
u_ap = -u_ap
return u_ap
def 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 的質量(拒絕對小尾巴的偽谷底)。
回傳 (th 或 None, ratio 或 None)。"""
lo = int(np.floor(aps.min()))
hi = int(np.ceil(aps.max()))
if hi - lo < 10:
return None, None
hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
sm = np.convolve(hist, np.ones(smooth) / float(smooth), mode='same')
csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1]
best_i, best_ratio = None, 1.0
for i in range(1, len(hist) - 1):
if not (sm[i] <= sm[i - 1] and sm[i] <= sm[i + 1]):
continue
if csum[i] < min_side_frac * total or (total - csum[i + 1]) < min_side_frac * total:
continue
flank = min(float(sm[:i].max()), float(sm[i + 1:].max()))
if flank <= 0:
continue
ratio = float(sm[i]) / flank
if ratio <= max_ratio and ratio < best_ratio:
best_ratio, best_i = ratio, i
if best_i is None:
return None, None
return float(0.5 * (edges[best_i] + edges[best_i + 1])), best_ratio
def _posterior_min_threshold(aps, rear_frac=0.40, min_side_frac=0.08, smooth=3):
"""AP 分佈後側 `rear_frac` 區間內、平滑直方圖的最小值位置(該處後側質量
比例需 >= min_side_frac棘突缺如椎體中 _full_ap_valley 失敗時的
「椎體後側末端 = 體/弓最薄處」備援。
回傳 th 或 None。"""
lo = int(np.floor(aps.min()))
hi = int(np.ceil(aps.max()))
if hi - lo < 10:
return None
hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
sm = np.convolve(hist, np.ones(smooth) / float(smooth), mode='same')
csum = np.concatenate([[0], np.cumsum(hist)])
total = csum[-1]
i0 = int(len(hist) * (1.0 - rear_frac))
best_i, best_val = None, np.inf
for i in range(i0, len(hist)):
if (total - csum[i + 1]) < min_side_frac * total:
continue
if sm[i] < best_val:
best_val, best_i = float(sm[i]), i
if best_i is None:
return None
return float(0.5 * (edges[best_i] + edges[best_i + 1]))
def segment_vertebral_body(mask_zyx, sym, endplate, ap_thresh, sp_mode, margin=2.0,
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
只保留終板下側身體側e·p-d <= margin的 bone voxel
排除跨在終板上方的後側構造(椎板/棘突)。
2) best_symmetry_plane 的鏡稱面:提供平面內 AP 軸_ap_axis
+AP = 後側,與 segment_spinous_process 同慣例AP 切點取三層優先:
a) sp_mode == 'gap':中線帶(椎管)的體/弓谷底 ap_thresh
segment_spinous_process 回傳值,最可靠);
b) sp_mode == 'no_spinous'(棘突已切除):中線帶沒有空的體/弓谷底
可用;椎體後側末端 = 終板下整體 AP 分佈的局部最小(體/弓最薄處)。
切除後殘留後側要素會部分填滿椎管、谷底比正常椎體淺,
因此放寬 _full_ap_valley 條件min_side_frac 0.15->0.10、
max_ratio 0.85->0.95);再失敗則取後側 40% 區間的平滑谷底
_posterior_min_threshold
c) 否則:終板下整體 AP 分佈的平滑谷底_full_ap_valley
處理中線骨橋等中線搜尋 fallback 的情形);
d) 最後 fallbackAP 分佈 55 百分位(可能切進椎體內,會打 WARNING
椎體 = AP < 切點(切點之前側)且終板下側的 bone。
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)
info = {'n_bone': int(zz.size), 'n_vb': 0, 'ap_thresh': None, 'mode': 'empty'}
if zz.size < 50:
return None, None, info
if endplate is None:
info['mode'] = 'no_endplate'
return None, None, info
a, b, c, d = endplate['plane']
X = xx.astype(np.float64)
Y = yy.astype(np.float64)
Z = zz.astype(np.float64)
below = (X * a + Y * b + Z * c - d) <= float(margin)
u_ap = _ap_axis(sym)
ap = X * u_ap[0] + Y * u_ap[1] + Z * u_ap[2]
aps = ap[below]
if aps.size < 50:
info['mode'] = 'too_few_below'
return None, None, info
th, mode = None, 'quantile'
if sp_mode == 'gap' and ap_thresh is not None:
th, mode = float(ap_thresh), 'midline_gap'
elif sp_mode == 'no_spinous':
# 棘突已切除:用放寬條件的整體谷底(見 docstring b
th, _ = _full_ap_valley(aps, min_side_frac=0.10, max_ratio=0.95)
if th is not None:
mode = 'nosp_gap'
else:
th = _posterior_min_threshold(aps)
if th is not None:
mode = 'nosp_post_min'
else:
th, _ = _full_ap_valley(aps)
if th is not None:
mode = 'full_gap'
if th is None:
th = float(np.quantile(aps, 0.55))
sel = below & (ap < th)
if not sel.any():
info['mode'] = 'no_body_voxels'
return None, None, info
# 側向包絡(見 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, lat_clip=lat_clip)
return vb_mask, th, info
def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=4.0,
n_iter=500, seed=42):
"""
3D bone mask (z, y, x) 的最佳「上終板」近似平面 a·x + b·y + c·z = d
voxel index 座標;(a,b,c) 為朝上的單位法線)。
區域:只用該 level 的前側半邊y 為 AP 方向anterior = y 較大側):
切點 = bone 的 y 範圍中點y >= (y_min+y_max)//2避開後方
棘突 / 弓根 / 椎管結構,與 2D superior endplate 定義一致。
兩階段穩健擬合(舊式單次 3D RANSAC 會被側旁結構 / 上層椎體侵入
的頂面污染擬出過陡平面L1 擬到 21°
1) Stage A矢狀線每個 (y, x) 欄位取最上方 bone voxel 作為頂面點,
再收斂成 per-y 最上 zRANSAC 擬合矢狀 (y-z) 線 z = s·y + i
取得穩健的矢狀斜率 s
2) Stage B側向線先把頂面點筛到矢狀線 ±max(2·thresh, 5) 帶內
(移除離帶的高/低污染點),再 RANSAC 擬合 (z s·y i) = c·x + k
取得側向斜率 c
3) 平面 z = s·y + c·x + (i+k) → 單位法線 (c,s,1)/√(1+s²+c²)、
d = (i+k)/√(1+s²+c²)、tilt_deg = 法線與 +z 軸夾角;
inlier_ratio = 全部前側半頂面點落在平面 ±thresh 的比例。
回傳 dict資料不足時回傳 None
plane : (a, b, c, d)
normal / offset
tilt_deg : 法線與 +z 軸的夾角(上終板傾斜)
inlier_ratio : 頂面點落在平面 ±thresh 的比例
n_points / n_inliers
u, v : 平面內正交方向(供繪製用)
"""
from sklearn.linear_model import RANSACRegressor
m = np.asarray(mask_zyx) > 0
nz, ny, nx = m.shape
if not m.any():
return None
idx = np.where(m, np.arange(nz)[:, None, None], -1)
ztop = idx.max(axis=0) # (y, x) 每欄最上 z
# 前側半邊bone y 範圍中點為切點(前側 = y >= 切點)
y_present = np.where(np.any(m, axis=(0, 2)))[0]
y_split = int((y_present[0] + y_present[-1]) // 2)
# ztop 是 (y, x):條件作用在 y 軸axis 0
sel = (ztop >= 0) & (np.arange(ny)[:, None] >= y_split)
yy, xx = np.where(sel)
if xx.size < 20:
return None
xa = xx.astype(np.float64)
ya = yy.astype(np.float64)
za = ztop[yy, xx].astype(np.float64)
n = za.size
def _line1d(x1, y1, residual):
"""RANSAC 擬線 y = k·x + b失敗時退回普通最小二乘。"""
try:
fit = RANSACRegressor(residual_threshold=float(residual),
max_trials=int(n_iter),
random_state=int(seed)).fit(x1.reshape(-1, 1), y1)
return float(fit.estimator_.coef_[0]), float(fit.estimator_.intercept_)
except Exception:
k, b = np.polyfit(x1, y1, 1)
return float(k), float(b)
# ---- Stage A矢狀 (y-z) 線 → 穩健矢狀斜率 s ----
zt = np.full(ny, -1.0)
np.maximum.at(zt, yy, za)
yv = np.where(zt >= 0)[0]
if yv.size < 10:
return None
s, i = _line1d(yv.astype(np.float64), zt[yv], 5.0)
if abs(float(np.degrees(np.arctan(s)))) > angle_max:
return None
# ---- Stage B固定 s擬側向 (x) 線 (z s·y i) = c·x + k ----
# 合起來 z = s·y + c·x + (i + k) → n0·p = d0n0 = (c, s, 1), d0 = i + k
res_sag = za - s * ya - i
band = np.abs(res_sag) <= max(2.0 * float(thresh), 5.0)
if int(band.sum()) < 20:
band = np.ones(n, dtype=bool)
c, k = _line1d(xa[band], res_sag[band], float(thresh))
d0 = i + k
n0 = np.array([-c, -s, 1.0])
norm0 = float(np.linalg.norm(n0))
nv = n0 / norm0
d = float(d0 / norm0)
tilt_deg = float(np.degrees(np.arccos(np.clip(nv[2], -1.0, 1.0))))
if tilt_deg > angle_max:
return None
dist = np.abs(-c * xa - s * ya + za - d0) / norm0
n_in = int(np.count_nonzero(dist <= float(thresh)))
u = np.cross(nv, [1.0, 0.0, 0.0])
u = u / np.linalg.norm(u)
v = np.cross(nv, u)
return {
'plane': (float(nv[0]), float(nv[1]), float(nv[2]), d),
'normal': (float(nv[0]), float(nv[1]), float(nv[2])),
'offset': d,
'tilt_deg': tilt_deg,
'inlier_ratio': float(n_in / n),
'n_points': int(n),
'n_inliers': n_in,
'u': (float(u[0]), float(u[1]), float(u[2])),
'v': (float(v[0]), float(v[1]), float(v[2])),
}
def azimuth_rotation(image, show_plt=False, save_plt=False, output_path=None):
img = sitk.ReadImage(image, sitk.sitkUInt8)
arr_zyx = sitk.GetArrayFromImage(img) # (z, y, x)
max_proj = np.max(arr_zyx, axis=0) # -> (y, x)
binary_proj = (max_proj > 0).astype(np.uint8)
ys, xs = np.where(binary_proj > 0)
if len(xs) < 10:
raise ValueError("Not enough foreground points")
# 2) centroid ←←← 這裡一定會定義 cx, cy
cy = ys.mean()
cx = xs.mean()
centroid = np.array([cy, cx])
cy = ys.mean()
cx = xs.mean()
centroid = np.array([cy, cx])
y_min = ys.min()
top_row_mask = (ys == y_min)
xs_top_row = xs[top_row_mask]
# 取這一排的中位數或平均值
x_center = int(np.median(xs_top_row)) # 或用 np.mean()
top_point = (y_min, x_center)
# print(f"最上排中心點: {top_point}")
# 計算從 top_point 到 centroid 的向量
dy = cy - y_min # y 方向的變化
dx = cx - x_center # x 方向的變化
# 計算與 y 軸的夾角
# 注意:影像座標系中 y 軸向下,所以要特別處理
angle_rad = np.arctan2(dx, dy) # 弧度
angle_deg = np.degrees(angle_rad) # 轉成角度
if show_plt:
fig = plt.figure(figsize=(12, 12))
# 視覺化時加上角度資訊
plt.imshow(binary_proj, cmap='gray')
plt.scatter(x_center, y_min, c='red', s=60, label='Top')
plt.scatter(cx, cy, c='yellow', s=60, label='Centroid')
plt.plot([x_center, cx], [y_min, cy], 'c-', lw=2,
label=f'Angle with y-axis: {angle_deg:.1f}°')
# 中矢狀面:最佳鏡稱面 a·x + b·y + c·z = d法線方向任意不平行 YZ 面)
# 畫該平面在體積中央 z 切片上的截線
sym3 = best_symmetry_plane(arr_zyx)
a3, b3, c3, d3 = sym3['plane']
zc_ = (arr_zyx.shape[0] - 1) / 2.0
rhs_ = d3 - c3 * zc_ # a*x + b*y = rhs
n2d = np.hypot(a3, b3)
x0_ = a3 * rhs_ / (n2d * n2d)
y0_ = b3 * rhs_ / (n2d * n2d)
u2_ = np.array([b3, -a3]) / n2d
tfg_ = (xs - x0_) * u2_[0] + (ys - y0_) * u2_[1]
L_ = float(np.abs(tfg_).max())
plt.plot([x0_ - L_ * u2_[0], x0_ + L_ * u2_[0]],
[y0_ - L_ * u2_[1], y0_ + L_ * u2_[1]],
color='magenta', lw=2, linestyle='--',
label=f'Mirror plane: {a3:+.2f}x {b3:+.2f}y {c3:+.2f}z = {d3:.1f}')
plt.legend()
plt.title("Top point + centroid-directed line")
plt.axis("off")
if save_plt:
if output_path is None:
output_path = "azimuth_rotation.png"
fig.savefig(output_path, dpi=200, bbox_inches="tight")
plt.show()
plt.close(fig)
return angle_deg
def split_spine_anterior_posterior(image_path, center_mode='com'):
"""
從 sagittal view 看,沿著 y 軸(前後方向)將脊椎切成前半部和後半部
Parameters:
image_path (str): 影像路徑
center_mode (str): 'com' 使用質心的 y 座標,'image' 使用圖片中心的 y 座標
Returns:
anterior, posterior: 前半部和後半部的 binary mask
"""
# Sagittal projection: 沿著 x 軸投影 -> (z, y)
img = sitk.ReadImage(image_path, sitk.sitkUInt8)
arr_zyx = sitk.GetArrayFromImage(img)
# Sagittal projection
max_proj_sagittal = np.max(arr_zyx, axis=2)
binary_proj = (max_proj_sagittal > 0).astype(np.uint8)
# 決定切割的 y 座標
if center_mode == 'com':
cz, cy = center_of_mass(binary_proj)
split_y = int(round(cy))
label = f'Center of Mass (y={split_y})'
elif center_mode == 'image':
split_y = binary_proj.shape[1] // 2
label = f'Image Center (y={split_y})'
# 檢查是否為數字,且範圍在 0 到 1 之間 (不含邊界)
elif isinstance(center_mode, (int, float)) and 0 < center_mode < 1:
ys = np.where(binary_proj > 0)[1]
if ys.size == 0: # 額外保險:如果投影是空的
split_y = binary_proj.shape[1] // 2
else:
y_min, y_max = ys.min(), ys.max()
split_y = int(round(y_min + center_mode * (y_max - y_min)))
label = f'Custom Ratio {center_mode} (y={split_y})'
else:
raise ValueError("center_mode 必須是 'com''image' 或介於 0 到 1 之間的浮點數 (例如 0.8)")
# 切割anterior (y < split_y) 和 posterior (y >= split_y)
anterior = binary_proj.copy()
posterior = binary_proj.copy()
anterior[:, :split_y] = 0 # 保留spine前半部(image後半部)y >= split_y
posterior[:, split_y:] = 0 # 保留spine後半部(image前半部)y < split_y
return anterior, posterior, binary_proj
def analyze_vertebral_tilt_contour(image_path, edge_type='superior', show_plot=False, debug=False, save_plt=False, output_path=None):
"""
通過椎體前緣輪廓分析傾斜(可選上或下終板)
Parameters:
edge_type: 'superior' 上終板, 'inferior' 下終板, 'both' 兩者都分析
"""
# 切割出 anterior 部分
anterior, posterior, binary_proj = split_spine_anterior_posterior(image_path, center_mode='com')
zs, ys = np.where(anterior > 0)
if len(zs) == 0:
return None
from sklearn.linear_model import RANSACRegressor
results = {}
# === 根據 edge_type 決定要分析哪些邊 ===
edges_to_analyze = []
if edge_type == 'superior' or edge_type == 'both':
edges_to_analyze.append('superior')
if edge_type == 'inferior' or edge_type == 'both':
edges_to_analyze.append('inferior')
all_edge_points = {}
all_inliers = {}
all_outliers = {}
all_slopes = {}
all_intercepts = {}
all_angles = {}
for edge in edges_to_analyze:
# 對每個 y找對應的邊緣點
edge_points = []
unique_ys = np.unique(ys)
for y in unique_ys:
z_at_y = zs[ys == y]
if edge == 'superior':
z_edge = z_at_y.max() # 最上面的點z 最小)
else: # inferior
z_edge = z_at_y.min() # 最下面的點z 最大)
edge_points.append([y, z_edge])
edge_points = np.array(edge_points)
all_edge_points[edge] = edge_points
if len(edge_points) < 10:
continue
# RANSAC 擬合
X = edge_points[:, 0].reshape(-1, 1)
y_data = edge_points[:, 1]
ransac = RANSACRegressor(
residual_threshold=5.0,
random_state=42
)
ransac.fit(X, y_data)
inlier_mask = ransac.inlier_mask_
outlier_mask = ~inlier_mask
edge_points_inliers = edge_points[inlier_mask]
edge_points_outliers = edge_points[outlier_mask]
all_inliers[edge] = edge_points_inliers
all_outliers[edge] = edge_points_outliers
# 獲取擬合結果
slope = ransac.estimator_.coef_[0]
intercept = ransac.estimator_.intercept_
all_slopes[edge] = slope
all_intercepts[edge] = intercept
# 計算 R²
y_pred = ransac.predict(edge_points_inliers[:, 0].reshape(-1, 1))
ss_res = np.sum((edge_points_inliers[:, 1] - y_pred) ** 2)
ss_tot = np.sum((edge_points_inliers[:, 1] - np.mean(edge_points_inliers[:, 1])) ** 2)
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
# 計算傾斜角度
tilt_angle = np.degrees(np.arctan(slope))
all_angles[edge] = tilt_angle
if debug:
print(f"\n=== {edge.upper()} ENDPLATE ===")
print(f"Total points: {len(edge_points)}")
print(f"Inliers: {len(edge_points_inliers)}")
print(f"Outliers: {len(edge_points_outliers)}")
print(f"{edge.capitalize()} 終板傾斜角度: {tilt_angle:.2f}°")
print(f"斜率: {slope:.4f}, R²: {r_squared:.4f}")
results[edge] = {
'tilt_angle_deg': tilt_angle,
'slope': slope,
'intercept': intercept,
'r_squared': r_squared,
'n_inliers': len(edge_points_inliers),
'n_outliers': len(edge_points_outliers)
}
# Visualization
if show_plot:
n_edges = len(edges_to_analyze)
fig, axes = plt.subplots(n_edges, 2, figsize=(16, 6*n_edges))
if n_edges == 1:
axes = axes.reshape(1, -1)
colors = {'superior': 'red', 'inferior': 'cyan'}
for idx, edge in enumerate(edges_to_analyze):
edge_points = all_edge_points[edge]
edge_points_inliers = all_inliers[edge]
edge_points_outliers = all_outliers[edge]
slope = all_slopes[edge]
intercept = all_intercepts[edge]
tilt_angle = all_angles[edge]
color = colors[edge]
# 左圖scatter plot
ax_left = axes[idx, 0]
if len(edge_points_outliers) > 0:
ax_left.scatter(edge_points_outliers[:, 0], edge_points_outliers[:, 1],
c='lightcoral', s=30, alpha=0.6, marker='x',
label=f'Outliers ({len(edge_points_outliers)})', zorder=3)
ax_left.scatter(edge_points_inliers[:, 0], edge_points_inliers[:, 1],
c=color, s=20, alpha=0.7,
label=f'Inliers ({len(edge_points_inliers)})', zorder=4)
# 擬合線
y_line = np.array([edge_points[:, 0].min(), edge_points[:, 0].max()])
z_line = slope * y_line + intercept
ax_left.plot(y_line, z_line, 'lime', linewidth=3,
label=f'Angle: {tilt_angle:.1f}°\nR²: {results[edge]["r_squared"]:.3f}',
zorder=5)
ax_left.set_xlabel('y')
ax_left.set_ylabel('z')
ax_left.set_title(f'{edge.capitalize()} Endplate Analysis\nTilt: {tilt_angle:.1f}°')
ax_left.invert_yaxis()
ax_left.legend()
ax_left.grid(True, alpha=0.3)
# 右圖:原始影像
ax_right = axes[idx, 1]
ax_right.imshow(anterior, cmap='gray', aspect='equal')
if len(edge_points_outliers) > 0:
ax_right.scatter(edge_points_outliers[:, 0], edge_points_outliers[:, 1],
c='red', s=40, alpha=0.7, marker='x', label='Outliers', zorder=4)
ax_right.scatter(edge_points_inliers[:, 0], edge_points_inliers[:, 1],
c=color, s=25, alpha=0.8, label='Inliers', zorder=3)
ax_right.plot(y_line, z_line, 'lime', linewidth=3, linestyle='--',
label=f'{edge.capitalize()}: {tilt_angle:.1f}°', zorder=5)
ax_right.set_title(f'Anterior Half - {edge.capitalize()} Edge')
ax_right.set_xlabel('y')
ax_right.set_ylabel('z')
ax_right.invert_yaxis()
ax_right.legend()
plt.tight_layout()
if save_plt:
if output_path is None:
output_path = "analyze_vertebral_tilt_contour.png"
fig.savefig(output_path, dpi=200, bbox_inches="tight")
plt.show()
plt.close(fig)
return results