CBT_project/xfr_orig_labels.py
Xiao Furen d167c1f7c7 feat(imaging): implement coordinate transformation pipeline and directory restructuring
Introduce a robust coordinate transformation system to manage the relationship
between original CT space and rotated/standardized segmentation spaces.
This includes a new directory hierarchy to separate unrotated crops from
rotated outputs and utility functions for geometric mapping.

Key changes:
- Implement `imaging/transforms.py` to handle bounding box metadata,
  affine standardization, and coordinate mapping between spaces.
- Restructure dataset output: unrotated segmentation files (binary, SDF,
  ROI, etc.) are now stored in a `<vol>/crop/` subdirectory to distinguish
  them from `<vol>/rotated/` aligned versions.
- Add `level_file_path` utility to abstract file discovery across legacy
  (top-level) and new (crop-based) directory structures.
- Enhance `seg_bone` to capture and export bounding box metadata
  (`bbox2`, `nn_bbox`, `bbox_orig`) into `transform.json`.
- Implement `xfr_cbt_native.py` for mapping screw positions back to
  original CT space.
- Update preprocessing and visualization scripts to support the new
  directory layout and transformation metadata.
- Improve TinyDB metadata migration logic to prevent accidental corruption
  of existing database structures.
2026-09-09 13:39:47 +08:00

628 lines
No EOL
28 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.

#!/home/xfr/.conda/envs/cbt/bin/python
"""把每個 lumbar level 的 rotated/{L}_label.nii.gz旋轉座標
1=VBODY 椎體、2=SP 棘突、3=other bone反旋轉回原始座標
把所有 level 合併成「每 patient 一個」.nii.gz存到 CTSpine1K
原始 dataset 下面(座標系與 data/ 裡的原始影像檔一致)。
做法:
1) 依 level 重算對齊旋轉 (R, c_xyz)(與 _write_rotated_level 完全
同路徑_smd_resampled 有效時以 <0.5 取骨頭 mask否則 _binary_nn
以 >0再從旋轉 label 檔與未旋轉模板檔的 origin 差回復裁切
起點 fstart(O_rot - O_tpl) 換元、必須為整數)。
2) NN 反向映射把 label 映回未旋轉 0.5mm 模板 grid。旋轉是 index 空間
剛性變換(物理座標系統只平移 fstart所以換元後只是單純的
R 旋轉,不需要重新估算任何平面。
3) std0.5mm 標準化空間)-> 原始 CT 物理座標的對角仿射 mapping
standardize_affine 每翻一個軸就把整份資料平移到世界原點的鏡射面
b_i = -(2O_i + d_i·0.5·(L_i-1)),翻軸條件 x: d_x=+1、y: d_y=+1、
z: d_z=-1ap_flipmetadata db再令 y 軸鏡射。全部參數都可從
原始 CT 幾何 + ap_flip 算出,見 _std_to_orig_affine。
4) NN 重取樣到原始 CT grid/mnt/1220/Public/dataset/Spine/CTSpine1K/
data/<子目錄>/<name>.nii.gz 的幾何),依 level 重編號後合併:
Lx 的 VBODY = 2x-1、SP = 2xL1..L6 -> 1..120 = 背景;
rotated label 的 3other bone不納入。
每 level 驗證重算的 (R, c):主要用正向 identity —— 以 _write_rotated_level
完全同參數重旋轉未旋轉 check 檔_smd_resampled 三線性、缺則
_binary_nn NN與存檔旋轉檔逐體素比對mismatch > 1e-4 跳過
(精確驗證 (R,c) 與該輸出生成版本一致,不受薄結構影響);無可比檔
時 fallback 用 round-trip 包含率(旋轉 check 檔反向旋轉後與未旋轉
bone mask 互相包含於 2-voxel 膨脹內min 方向 < --min-contain
跳過該 level
寫檔前對位檢查:合併 label 落在原始 CT 體內HU > -100比例 < 0.98
或 median HU < 100 時判定錯位、跳過。
輸出:<dest>/<子目錄>/<name>.nii.gzuint8預設 dest =
/mnt/1220/Public/dataset/Spine/CTSpine1K/label_vbody_sp/
檔名與原始影像檔相同。已存在的輸出預設跳過(--force 覆蓋)。
"""
import argparse
import json
import logging
import os
import sys
import time
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)
from imaging.transforms import level_file_path
from visualization.res_bone_figure import (compute_normalizing_rotation,
rotated_grid, rotate_volume_to)
DATA_ROOT = '/mnt/1220/Public/dataset/Spine/CTSpine1K/data/'
DATASET_ROOT = '/mnt/1220/Public/dataset/Spine/CTSpine1K/'
DEFAULT_DEST = os.path.join(DATASET_ROOT, 'label_vbody_sp')
# 預設依序掃的世代資料夾(先新後舊;同名 volume 以先處理者為準)
DEFAULT_ROOTS = [
'/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-3',
'/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-2',
]
LUMBAR_LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5', 'L6')
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
METADATA_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'xfr_image_metadata.json')
logger = logging.getLogger('xfr_orig_labels')
def load_ap_flip():
"""metadata dbTinyDB 純 JSON{'images': {doc_id: doc}})的
name -> ap_flipbooldb 缺失 / 解析失敗時回 {}"""
try:
with open(METADATA_DB) as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f'metadata db {METADATA_DB} 讀取失敗:{e}')
return {}
tab = data.get('images') if isinstance(data, dict) else None
if not isinstance(tab, dict):
return {}
return {v['name']: bool(v.get('ap_flip', False))
for v in tab.values() if isinstance(v, dict) and 'name' in v}
# ---------------------------------------------------------------- log 設定
def setup_tee(log_path):
"""console 與 log 檔同時輸出append、line-buffered"""
class _Tee:
def __init__(self, console, fh):
self.console, self.fh, self.buf = console, fh, ''
def write(self, data):
if not data:
return
self.buf += data
while True:
idx_n, idx_r = self.buf.find('\n'), self.buf.find('\r')
idx = min([i for i in (idx_n, idx_r) if i != -1], default=-1)
if idx == -1:
break
line, self.buf = self.buf[:idx], self.buf[idx + 1:]
self.console.write(line + '\n')
self.fh.write(line + '\n')
def flush(self):
if self.buf:
line, self.buf = self.buf, ''
self.console.write(line + '\n')
self.fh.write(line + '\n')
self.console.flush()
self.fh.flush()
fh = open(log_path, 'a', buffering=1)
sys.stdout = _Tee(sys.stdout, fh)
sys.stderr = _Tee(sys.stderr, fh)
# ---------------------------------------------------------------- 幾何工具
def _axis_vectors(img):
"""direction 為純對角±1時回傳 (signs (x,y,z), spacing),否則 None。"""
d = np.asarray(img.GetDirection(), dtype=float).reshape(3, 3)
diag = np.diag(d)
if not (np.allclose(d, np.diag(diag), atol=1e-6)
and np.allclose(np.abs(diag), 1.0, atol=1e-6)):
return None
return diag, np.asarray(img.GetSpacing(), dtype=float)
def _fstart_from_geometries(rot_img, t_img):
"""從旋轉檔與未旋轉模板檔的 origin 差回復 fstart模板 index 系,
(x,y,z) 整數);幾何不一致(非對角 direction、spacing / direction
不同)或差不是整數時回傳 None。"""
ax_r, ax_t = _axis_vectors(rot_img), _axis_vectors(t_img)
if ax_r is None or ax_t is None:
return None
(d_r, s_r), (d_t, s_t) = ax_r, ax_t
if not (np.allclose(d_r, d_t, atol=1e-6) and np.allclose(s_r, s_t, atol=1e-6)):
return None
o_r = np.asarray(rot_img.GetOrigin(), dtype=float)
o_t = np.asarray(t_img.GetOrigin(), dtype=float)
fs = (o_r - o_t) / (d_t * s_t)
if not np.allclose(fs, np.round(fs), atol=1e-6):
return None
return np.round(fs).astype(int)
def _std_to_orig_affine(orig_img, t_img, ap_flip):
"""std0.5mm 裁切檔、standardize_affine 後、ap_flip 後)-> 原始 CT
物理座標LPSP_orig = A·P_std + b。回傳 (A (3,) 對角, b (3,))
原始 direction 非純對角時回傳 None。t_img = 該 level 的未旋轉標準化
裁切檔disk 上的後 standardize 幾何)。
standardize_affine 是【逐檔(每 level 裁切】處理翻軸nibabel
affine 對角與平移同反號 + 資料 flip在 worlds 中是整份資料平移
t_i = 2·O_post_i - d_i·h·(L_i-1)O_post/L 為 disk 上該檔的
origin/尺寸、d 為原始 direction、h=0.5;翻軸條件 RAS 對角 < 0
= x: d_x=+1、y: d_y=+1、z: d_z=-1故 b_i = -t_i翻軸的軸
ap_flip 是 y 軸純資料鏡射(幾何不變,作用在整張 0.5mm full grid
y_ap = C - y_origC = 2·O_full_y + d_y·h·(L_full_y-1)full grid
幾何 = 原始 CT發生在 standardize 之前。"""
d_full = np.asarray(orig_img.GetDirection(), dtype=float).reshape(3, 3)
d = np.diag(d_full)
if not (np.allclose(d_full, np.diag(d), atol=1e-6)
and np.allclose(np.abs(d), 1.0, atol=1e-6)):
return None
h = 0.5
O_full = np.asarray(orig_img.GetOrigin(), dtype=float)
s = np.asarray(orig_img.GetSpacing(), dtype=float)
n = np.asarray(orig_img.GetSize(), dtype=float) # (x, y, z)
L_full = np.ceil(n * s / h - 1e-6) # 0.5mm full grid
O_post = np.asarray(t_img.GetOrigin(), dtype=float)
L = np.asarray(t_img.GetSize(), dtype=float) # 裁切檔尺寸
flips = (d[0] == 1.0, d[1] == 1.0, d[2] == -1.0)
A = np.ones(3)
b = np.zeros(3)
for i in range(3):
if flips[i]:
b[i] = -(2.0 * O_post[i] - d[i] * h * (L[i] - 1.0))
if ap_flip:
# y0 --AP 鏡射--> y1 = C - y0 --std 平移--> y_std = y1 + t_y
# => y0 = -y_std + (C + t_y)
A[1] = -1.0
C_full = 2.0 * O_full[1] + d[1] * h * (L_full[1] - 1.0)
if flips[1]:
t_y = 2.0 * O_post[1] - d[1] * h * (L[1] - 1.0)
b[1] = C_full + t_y
else:
b[1] = C_full
return A, b
def _affine_sitk(A, b):
"""對角 A + 平移 b 的 sitk 仿射LPS 物理 -> LPS 物理)。
p' = A·p + bSetMatrix 為 row-major 3x3、SetTranslation 獨立。
自檢三軸 + 原點。"""
tr = sitk.AffineTransform(3)
tr.SetMatrix([float(A[0]), 0.0, 0.0,
0.0, float(A[1]), 0.0,
0.0, 0.0, float(A[2])])
tr.SetTranslation([float(b[0]), float(b[1]), float(b[2])])
for i in range(3):
p = [0.0, 0.0, 0.0]
p[i] = 123.0
got = np.array(tr.TransformPoint(tuple(p)))
exp = np.array(b, dtype=float)
exp[i] = A[i] * 123.0 + b[i]
if not np.allclose(got, exp, atol=1e-6):
raise RuntimeError(f'affine self-check failed: axis {i} '
f'{got} != {exp}')
return tr
def _rotate_back(arr, R, c_xyz, fstart, shape_zyx, order=0, cval=0.0):
"""rotated grid 上的 field 反向旋轉到未旋轉模板 gridshape_zyx
rotated 檔 array 座標 a = 模板座標 V - fstart反向映射
V = R(W - c) + cW 為模板 grid 上每格x,y,z 向量,同
compute_normalizing_rotation 慣例。order / cval 同 map_coordinates。"""
nz, ny, nx = shape_zyx
idx = np.indices((nz, ny, nx), dtype=np.float64)
W = np.stack([idx[2], idx[1], idx[0]], axis=0) # (3, z, y, x)
c = np.asarray(c_xyz, dtype=np.float64)[:, None, None, None]
Rf = np.asarray(R, dtype=np.float64)
V = np.tensordot(Rf, W - c, axes=([1], [0])) + c
fs = np.asarray(fstart, dtype=np.float64)[:, None, None, None]
a = V - fs # rotated 檔 array 座標
return map_coordinates(arr, [a[2], a[1], a[0]],
order=order, cval=cval, mode='constant')
# ---------------------------------------------------------------- level 處理
def _load_unrotated_template(vol_dir, level):
"""_write_rotated_level 的旋轉來源_smd_resampled有效時否則
_binary_nn。回傳 (img, arr, is_smd);皆無則 None。
未旋轉檔位置經 level_file_path新世代 crop/、舊世代頂層)。"""
smd_path = level_file_path(vol_dir, level, 'smd_resampled')
if os.path.exists(smd_path):
sa = sitk.GetArrayFromImage(sitk.ReadImage(smd_path)).astype(np.float32)
if sa.size > 0 and np.isfinite(sa).all() and sa.min() < 0.0 and sa.max() > 0.0:
return sitk.ReadImage(smd_path), sa, True
nn_path = level_file_path(vol_dir, level, 'binary_nn')
if os.path.exists(nn_path):
na = sitk.GetArrayFromImage(sitk.ReadImage(nn_path))
if na.size > 0 and int((na > 0).sum()) > 0:
return sitk.ReadImage(nn_path), na, False
return None
def _bone_mask(t_arr, is_smd):
return (t_arr < 0.5) if is_smd else (t_arr > 0)
def _forward_identity_check(vol_dir, level, R, c_xyz, t_img, t_arr, is_smd):
"""以 _write_rotated_level 完全同參數fstart/fsize 由未旋轉 _binary_nn
旋轉後 bbox±4、order / cval 同 writer重旋轉、與存檔的旋轉檔逐體素
比對,回傳 mismatch 比例((R,c) 相同時 ≈0精確驗證、不受物件厚度
影響);無可比檔(缺檔 / 形狀不符)時回 None。"""
rotated_dir = os.path.join(vol_dir, 'rotated')
if is_smd:
target = os.path.join(rotated_dir, f'{level}_smd_resampled.nii.gz')
src_arr, order, cval = t_arr, 1, float(t_arr.max())
else:
target = os.path.join(rotated_dir, f'{level}_binary_nn.nii.gz')
src_arr, order, cval = (t_arr > 0).astype(np.uint8), 0, 0.0
if not os.path.exists(target):
return None
start, size = rotated_grid(t_arr.shape, R, c_xyz, margin=4)
fstart = (int(start[0]), int(start[1]), int(start[2]))
fsize = (int(size[0]), int(size[1]), int(size[2]))
nn_path = level_file_path(vol_dir, level, 'binary_nn')
if os.path.exists(nn_path):
nn_img = sitk.ReadImage(nn_path)
nn_arr = (sitk.GetArrayFromImage(
sitk.Resample(nn_img, t_img, interpolator=sitk.sitkNearestNeighbor,
defaultPixelValue=0)) > 0).astype(np.uint8)
rot_nn_full = (rotate_volume_to(nn_arr, R, c_xyz, fstart, fsize,
order=0, cval=0.0) > 0.5).astype(np.uint8)
if int(rot_nn_full.sum()) > 0:
zz, yy, xx = np.where(rot_nn_full > 0)
m = 4
z0 = max(0, int(zz.min()) - m)
z1 = min(rot_nn_full.shape[0] - 1, int(zz.max()) + m)
y0 = max(0, int(yy.min()) - m)
y1 = min(rot_nn_full.shape[1] - 1, int(yy.max()) + m)
x0 = max(0, int(xx.min()) - m)
x1 = min(rot_nn_full.shape[2] - 1, int(xx.max()) + m)
fstart = (fstart[0] + x0, fstart[1] + y0, fstart[2] + z0)
fsize = (x1 - x0 + 1, y1 - y0 + 1, z1 - z0 + 1)
out = rotate_volume_to(src_arr, R, c_xyz, fstart, fsize, order=order,
cval=cval)
saved = sitk.GetArrayFromImage(sitk.ReadImage(target))
if out.shape != saved.shape:
return None
if order == 0:
return float(((out > 0.5) != (saved > 0.5)).mean())
return float((np.abs(out.astype(np.float64)
- saved.astype(np.float64)) > 0.02).mean())
def _containment_roundtrip(vol_dir, level, R, c_xyz, fstart, t_arr, is_smd,
it=2):
"""fallback 驗證:旋轉 check 檔反向旋轉後與未旋轉 bone mask 互相包含於
it-voxel 膨脹內(薄結構的 round-trip IoU 天生低,用包含率代替)。
回傳 min 方向包含率;無 check 檔時回 None。"""
from scipy.ndimage import binary_dilation
rotated_dir = os.path.join(vol_dir, 'rotated')
candidates = [
(f'{level}_smd_resampled.nii.gz', 1),
(f'{level}_binary_sdf.nii.gz', 0),
(f'{level}_binary_nn.nii.gz', 0),
]
if not is_smd:
candidates = candidates[1:]
B0 = _bone_mask(t_arr, is_smd)
for fname, order in candidates:
p = os.path.join(rotated_dir, fname)
if not os.path.exists(p):
continue
a = sitk.GetArrayFromImage(sitk.ReadImage(p)).astype(np.float64)
if a.size == 0:
continue
cval = float(a.max()) if fname.endswith('_smd_resampled.nii.gz') else 0.0
b = _rotate_back(a, R, c_xyz, fstart, t_arr.shape, order=order,
cval=cval)
B1 = b < 0.5 if fname.endswith('_smd_resampled.nii.gz') else b > 0.5
if not B1.any() or not B0.any():
continue
da = binary_dilation(B1, iterations=it)
db = binary_dilation(B0, iterations=it)
return float(min((B1 & db).sum() / B1.sum(), (B0 & da).sum() / B0.sum()))
return None
def process_level(vol_dir, level, min_contain):
"""單一 level重算 (R, c)、回復 fstart、驗證、反向旋轉 label。
回傳 (unrotated_label_arr (z,y,x) uint8 0/1/2/3, t_img, info);失敗回 None。"""
name = os.path.basename(vol_dir)
rot_lbl_path = os.path.join(vol_dir, 'rotated', f'{level}_label.nii.gz')
if not os.path.exists(rot_lbl_path):
return None
rot_img = sitk.ReadImage(rot_lbl_path)
rot_arr = sitk.GetArrayFromImage(rot_img).astype(np.uint8)
tpl = _load_unrotated_template(vol_dir, level)
if tpl is None:
logger.warning(f'{name} {level}: 無可用未旋轉模板 '
f'_smd_resampled / _binary_nn跳過')
return None
t_img, t_arr, is_smd = tpl
fstart = _fstart_from_geometries(rot_img, t_img)
if fstart is None:
logger.warning(f'{name} {level}: 旋轉 / 未旋轉幾何不一致,跳過')
return None
bin_arr = _bone_mask(t_arr, is_smd).astype(np.uint8)
sym = best_symmetry_plane(bin_arr)
symp = best_upper_endplate_plane(bin_arr)
R, c_xyz = compute_normalizing_rotation(bin_arr, sym, symp)
# 驗證重算的 (R, c):主要用 forward identity與生成時同參數重旋轉、
# 逐體素比對存檔;(R,c) 相同時 ≈0無可比檔時 fallback 用
# round-trip 包含率min_contain 門檻)。
fwd = _forward_identity_check(vol_dir, level, R, c_xyz, t_img, t_arr, is_smd)
contain = None
if fwd is not None:
if fwd > 1e-4:
logger.warning(f'{name} {level}: 正向重旋轉 mismatch {fwd:.4f} '
f'> 1e-4(R,c) 與生成參數不符,跳過')
return None
else:
contain = _containment_roundtrip(vol_dir, level, R, c_xyz, fstart,
t_arr, is_smd)
if contain is None:
logger.warning(f'{name} {level}: 無 check 檔可驗證,跳過')
return None
if contain < min_contain:
logger.warning(f'{name} {level}: round-trip 包含率 {contain:.4f} '
f'< {min_contain},跳過該 level')
return None
unrot = _rotate_back(rot_arr.astype(np.float64), R, c_xyz, fstart,
t_arr.shape).astype(np.uint8)
n_lbl = int((unrot > 0).sum())
B0 = _bone_mask(t_arr, is_smd)
bone_frac = (int(((unrot > 0) & B0).sum()) / n_lbl) if n_lbl else 0.0
info = {
'forward_mismatch': fwd,
'containment': contain,
'fstart': [int(v) for v in fstart],
'bone_frac': bone_frac,
'rotated': {'vbody': int((rot_arr == 1).sum()),
'sp': int((rot_arr == 2).sum()),
'other': int((rot_arr == 3).sum())},
'unrotated': {'vbody': int((unrot == 1).sum()),
'sp': int((unrot == 2).sum()),
'other': int((unrot == 3).sum())},
}
return unrot, t_img, info
def _resample_nearest(ref_img, src_img, transform=None):
r = sitk.ResampleImageFilter()
r.SetReferenceImage(ref_img)
r.SetInterpolator(sitk.sitkNearestNeighbor)
r.SetDefaultPixelValue(0)
if transform is not None:
r.SetTransform(transform)
return r.Execute(src_img)
def process_volume(vol_dir, orig_img, key, dest_dir, min_contain, force,
ap_flip):
"""所有 level 反旋轉 + 合併 + 重取樣到原始 CT grid存「一個」.nii.gz。
回傳 (out_path, status, {level: info}, vol_info);失敗時 out_path=None、
status 為原因。"""
name = os.path.basename(vol_dir)
nxi, nyi, nzi = orig_img.GetSize() # sitk GetSize = (x, y, z)
merged = np.zeros((nzi, nyi, nxi), dtype=np.uint8) # array = (z, y, x)
level_info = {}
maps = []
for li, level in enumerate(LUMBAR_LEVELS):
res = process_level(vol_dir, level, min_contain)
if res is None:
continue
unrot, t_img, info = res
# std->orig mapping 依該 level 裁切檔幾何逐 level 算
aff = _std_to_orig_affine(orig_img, t_img, bool(ap_flip))
if aff is None:
logger.warning(f'{name} {level}: 原始 CT direction 非純對角,'
f'跳過')
continue
A, b = aff
# Resample 的 transform 方向是 dest(原始) physical -> src(std)
# physicalP_std = A·(P_orig - b) = A·P_orig - A*bA 對角 ±1
tr = _affine_sitk(A, -A * b)
# 重編號Lx VBODY=2x-1、SP=2xx = L1..L6 的 1-based
# other(3) 不納入
new = np.zeros_like(unrot)
new[unrot == 1] = 2 * li + 1
new[unrot == 2] = 2 * li + 2
maps.append({'A': [float(v) for v in A],
'b': [round(float(v), 3) for v in b]})
if not new.any():
logger.info(f'{name} {level}: 無 VBODY / SP voxel不計入')
continue
lvl_img = sitk.GetImageFromArray(new)
lvl_img.CopyInformation(t_img)
arr = sitk.GetArrayFromImage(_resample_nearest(orig_img, lvl_img, tr))
merged = np.where(arr > 0, arr, merged)
info['orig'] = {'vbody': int((arr == 2 * li + 1).sum()),
'sp': int((arr == 2 * li + 2).sum())}
level_info[level] = info
check = (f'fwd_mismatch={info["forward_mismatch"]:.1e}'
if info.get('forward_mismatch') is not None
else f'containment={info["containment"]:.4f}')
logger.info(f'{name} {level}: {check} '
f'bone_frac={info["bone_frac"]:.4f} '
f'std->orig A={np.round(A, 3)} b={np.round(b, 2)} '
f'orig vbody={info["orig"]["vbody"]} '
f'sp={info["orig"]["sp"]} (labels {2 * li + 1}/{2 * li + 2})')
if not level_info:
return None, 'no usable level', {}, {}
if int(merged.sum()) == 0:
return None, 'merged label empty after resample', level_info, {}
# 對位檢查label 應落在原始 CT 體內HU > -100且主要是骨頭
#median HU >= 100鬆質骨 HU 可 < 100故不用 bone 比例作硬門檻)
ct = sitk.GetArrayFromImage(orig_img)
hu = ct[merged > 0]
frac_body = float((hu > -100).mean())
med_hu = float(np.median(hu))
frac_bone = float((hu > 100).mean())
vol_info = {'in_body_frac': frac_body, 'bone_frac': frac_bone,
'median_hu': med_hu, 'std_to_orig': maps, 'ap_flip': bool(ap_flip)}
if frac_body < 0.98 or med_hu < 100:
logger.warning(f'{name}: 對位檢查 in_body={frac_body:.4f} '
f'median HU={med_hu:.0f},疑似錯位,跳過')
return None, f'alignment check failed ' \
f'(in_body={frac_body:.3f}, median_hu={med_hu:.0f})', \
level_info, vol_info
logger.info(f'{name}: alignment OK in_body={frac_body:.4f} '
f'bone_frac={frac_bone:.4f} median HU={med_hu:.0f}')
out_path = os.path.join(dest_dir, key, f'{name}.nii.gz')
if os.path.exists(out_path) and not force:
return out_path, 'exists (skipped)', level_info, vol_info
os.makedirs(os.path.dirname(out_path), exist_ok=True)
out_img = sitk.GetImageFromArray(merged)
out_img.CopyInformation(orig_img)
sitk.WriteImage(out_img, out_path)
logger.info(f'{name}: saved {out_path} (levels={list(level_info)})')
return out_path, 'written', level_info, vol_info
def find_original_ct(name):
"""在 DATA_ROOT 子目錄裡找 <name>.nii.gz回傳 (path, 子目錄) 或
(None, None)。"""
if not os.path.isdir(DATA_ROOT):
return None, None
for key in sorted(os.listdir(DATA_ROOT)):
p = os.path.join(DATA_ROOT, key, f'{name}.nii.gz')
if os.path.isfile(p):
return p, key
return None, None
def list_volumes_with_labels(root):
"""root 下有 rotated/*_label.nii.gz 的 volume 目錄(依名稱排序)。"""
vols = []
if not os.path.isdir(root):
return vols
for d in sorted(os.listdir(root)):
rot = os.path.join(root, d, 'rotated')
if os.path.isdir(rot) and any(
f.endswith('_label.nii.gz') for f in os.listdir(rot)):
vols.append(os.path.join(root, d))
return vols
def main():
parser = argparse.ArgumentParser(
description='Merge unrotated lumbar VBODY/SP labels into one '
'original-CT coordinate .nii.gz per patient.')
parser.add_argument('--roots', nargs='*', default=DEFAULT_ROOTS,
help='Output generation dir(s) to scan (newest first).')
parser.add_argument('--dest', default=DEFAULT_DEST,
help=f'Destination dir (default: {DEFAULT_DEST})')
parser.add_argument('--force', action='store_true',
help='Overwrite existing outputs.')
parser.add_argument('--min-contain', type=float, default=0.85,
help='Fallback per-level check: skip a level whose '
'unrotation round-trip containment is below '
'this (default: 0.85).')
parser.add_argument('--name', default=None,
help='Process only this volume name (debug).')
args = parser.parse_args()
os.makedirs(LOG_DIR, exist_ok=True)
log_path = os.path.join(LOG_DIR, f'xfr_orig_labels_{time.strftime("%Y%m%d_%H%M%S")}.log')
setup_tee(log_path)
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
logger.info(f'Log file: {log_path}')
logger.info(f'Command: {sys.executable} {" ".join(sys.argv)}')
logger.info(f'Roots: {args.roots}')
logger.info(f'Dest: {args.dest}')
logger.info('Label scheme: Lx VBODY=2x-1, SP=2x (L1:1/2, L2:3/4, '
'L3:5/6, L4:7/8, L5:9/10, L6:11/12); other bone excluded; '
'uint8; geometry = original CT')
os.makedirs(args.dest, exist_ok=True)
ap_flip_db = load_ap_flip()
logger.info(f'ap_flip metadata: {len(ap_flip_db)} entries '
f'({sum(1 for v in ap_flip_db.values() if v)} flipped)')
done, summary, no_ap = set(), {}, set()
summary_path = os.path.join(args.dest, 'summary.json')
if os.path.exists(summary_path):
try:
with open(summary_path) as f:
summary = json.load(f)
except (json.JSONDecodeError, OSError):
summary = {}
for root in args.roots:
vols = list_volumes_with_labels(root)
if args.name is not None:
vols = [v for v in vols if os.path.basename(v) == args.name]
logger.info(f'Scanning {root}: {len(vols)} volume(s) with rotated labels')
for vol_dir in vols:
name = os.path.basename(vol_dir)
if name in done:
logger.info(f'[skip] {name}: 已由較新世代處理')
continue
done.add(name)
orig_path, key = find_original_ct(name)
if orig_path is None:
logger.warning(f'[skip] {name}: 找不到原始 CT '
f'({DATA_ROOT}*/{name}.nii.gz)')
continue
ap_flip = ap_flip_db.get(name)
if ap_flip is None and name not in no_ap:
no_ap.add(name)
logger.warning(f'{name}: metadata db 缺 ap_flip假設 False '
f'(若為 AP 翻轉個案將對位失敗並被抓出)')
ap_flip = False
out_path = os.path.join(args.dest, key, f'{name}.nii.gz')
if os.path.exists(out_path) and not args.force:
logger.info(f'[skip] {name}: {out_path} 已存在')
continue
try:
orig_img = sitk.ReadImage(orig_path)
t0 = time.time()
out, status, level_info, vol_info = process_volume(
vol_dir, orig_img, key, args.dest, args.min_iou, args.force,
ap_flip)
dt = time.time() - t0
common = {'status': status, 'source_root': root,
'vol_info': vol_info,
'updated': time.strftime('%Y-%m-%d %H:%M:%S')}
if out is None:
logger.warning(f'[skip] {name}: {status} ({dt:.1f}s)')
summary[name] = {**common, 'dest_root': DATA_ROOT,
'levels': level_info}
else:
logger.info(f'[done] {name}: {status} in {dt:.1f}s')
summary[name] = {**common, 'path': out, 'key': key,
'levels': level_info}
except Exception as e:
logger.warning(f'[error] {name}: {e}')
summary[name] = {'status': f'error: {e}', 'source_root': root,
'updated': time.strftime('%Y-%m-%d %H:%M:%S')}
with open(summary_path, 'w') as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
logger.info('All done.')
if __name__ == '__main__':
main()