refactor(imaging): update coordinate transformation logic to use transform.json
Update the coordinate transformation pipeline to prioritize the affine transformation chain recorded in `transform.json` (original_to_source) instead of relying on re-calculated geometric parameters. The previous method relied on re-estimating rotation, center, and bounding boxes from the bone mask, which led to inaccuracies in screw pillar mapping (e.g., dropping from 99% to 30-94% in-label accuracy). The new approach uses the precise inverse affine mapping `o = M^-1 (s - t)` from the transformation metadata. A fallback mechanism is maintained for legacy volumes lacking `transform.json`, which continues to use the re-calculation method. - Implement `load_transform` and `original_to_source` integration - Update documentation to reflect the new primary/fallback coordinate chains - Improve precision of screw parameter mapping to native index space
This commit is contained in:
parent
d167c1f7c7
commit
0a928d8f8e
1 changed files with 69 additions and 29 deletions
|
|
@ -8,27 +8,34 @@
|
|||
label 值:L1L=1 L1R=2 L2L=3 L2R=4 L3L=5 L3R=6 L4L=7 L4R=8 L5L=9 L5R=10
|
||||
(0 = 背景)。
|
||||
|
||||
座標鏈(純 index 空間;不依賴 standardized 輸出的物理 header ——
|
||||
standardize_affine 的 origin 處理不可靠,2026-09-08 已驗證):
|
||||
rotated disk r((x,y,z) index)
|
||||
-> template 0.5mm: t = R^T (r + fstart - c) + c [行向量: (r+fstart-c) @ R + c]
|
||||
-> 記憶體 0.5mm 全域: g0 = bbox2s + (wx-1-tx, wy-1-ty, tz)
|
||||
-> ap_flip 時: g0y = N05y - 1 - g0y
|
||||
-> native index: rint(g0 * 0.5 / sn)
|
||||
座標鏈首選:直接用預處理寫檔時記錄在 <vol>/transform.json 的正向鏈
|
||||
(imaging.transforms.original_to_source:boxes / std_flip_axes / ap_flip /
|
||||
rotated R、center、start / 原 CT 幾何;純 index 空間、不依賴 standardized
|
||||
輸出的物理 header)。該鏈為仿射(original index <-> rotated disk index),
|
||||
螺絲點經仿射逆向 o = M^-1 (s - t) 精確映射——不重新估算任何平面 / 幾何、
|
||||
不重取樣 native label。
|
||||
|
||||
其中每 level 的 R/c 由 template 骨頭 mask(smd_resampled < 0.5,與
|
||||
_write_rotated_level 的輸入同定義;退化時退回 _binary_nn)重算;
|
||||
fstart = rotated 檔 origin 反映到 template index 的整數;
|
||||
bbox2 = native label 線性重取樣 0.5mm(>0.5)的最大 26-連通區域 bbox,
|
||||
與 seg_bone 同定義(template/roi/binary_sdf 都裁在這個 bbox2 上)。
|
||||
fallback(僅舊世代 volume 沒有 transform.json、或該 level 沒有 rotated
|
||||
記錄時):純 index 空間重算 R/center(template 骨頭 mask 的
|
||||
best_symmetry_plane / best_upper_endplate_plane)、fstart(rotated 檔
|
||||
origin 反映到 template index 的整數)、bbox2(native label 0.5mm 線性
|
||||
重取樣 >0.5 的最大 26-連通區域 bbox):
|
||||
rotated disk r -> t = R^T (r + fstart - c) + c
|
||||
-> g0 = bbox2s + (wx-1-tx, wy-1-ty, tz);ap_flip 時 g0y = N05y-1-g0y
|
||||
-> native: rint(g0 * 0.5 / sn)
|
||||
重算結果可能與生成時參數不完全一致:2026-09-09 驗證 liver_100 螺絲柱
|
||||
in-label 由 93-99%(transform.json)掉到 30-94%(重算);新世代資料
|
||||
(transform.json 在)一律走主路徑。
|
||||
|
||||
螺絲參數化(rotated 系、0.5mm index):pos = [z, y, x, az°, alt°, d mm, L mm];
|
||||
方向 d_v = (cos az sin alt, sin az sin alt, cos alt);末端 = p0 + L/0.5 * d_v;
|
||||
柱半徑 = d/2 mm。
|
||||
|
||||
驗證(2026-09-08):整顆骨頭 15/15 (volume, level) 100% 落在 native label
|
||||
(/tmp/kilo/validate_final.py);螺絲柱體全部點 20/20 level-side
|
||||
in-label >= 96%(2-voxel 膨脹)(/tmp/kilo/validate_screws2.py)。
|
||||
驗證(2026-09-09,transform.json 路徑):20260909_141448 run —
|
||||
0001(10/10)、liver_100(10/10)、covid19(L1/L2 六側)螺絲柱體
|
||||
in-label >= 92%、2-voxel 膨脹 >= 98%。covid19 L3 兩側除外:該 volume 的
|
||||
L3 旋轉 bone mask 是 4.5k voxel / 5 slice 的退化區域(L4/L5 預處理缺檔、
|
||||
先前棘突切除),屬資料問題非映射問題(L3 bone 碎片本身逆向 in-label 97.8%)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -45,7 +52,8 @@ from config.constant import LABEL_MAP
|
|||
from imaging.resample import resample_img
|
||||
from imaging.segmentation import _largest_cc_bbox
|
||||
from imaging.orientation import best_symmetry_plane, best_upper_endplate_plane
|
||||
from imaging.transforms import level_file_path
|
||||
from imaging.transforms import (level_file_path, load_transform,
|
||||
original_to_source, transform_path)
|
||||
from visualization.res_bone_figure import compute_normalizing_rotation
|
||||
|
||||
standardized_dir = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-3'
|
||||
|
|
@ -176,15 +184,32 @@ def write_volume_cbt(volume_id, run_id, output_root=Output_dir, date=None):
|
|||
|
||||
ct_path, lb_path, ap_flip = find_native_paths(volume_id)
|
||||
ct = sitk.ReadImage(ct_path)
|
||||
lb_img = sitk.ReadImage(lb_path)
|
||||
lb_arr = sitk.GetArrayFromImage(lb_img)
|
||||
Nn = np.array(ct.GetSize(), float)
|
||||
n_xyz = np.array(ct.GetSize(), int) # (x,y,z)
|
||||
n_zyx = n_xyz[::-1].copy() # 輸出 (z,y,x)
|
||||
sn = np.array(ct.GetSpacing(), float)
|
||||
N05 = np.maximum(1, np.ceil(Nn * sn / 0.5 - 1e-6).astype(int))
|
||||
N05 = np.maximum(1, np.ceil(n_xyz.astype(float) * sn / 0.5 - 1e-6).astype(int))
|
||||
|
||||
out_arr = np.zeros(lb_arr.shape, np.uint8)
|
||||
geom_cache = {}
|
||||
# 主路徑:transform.json 記錄的正向鏈(仿射),逆向 o = M^-1 (s - t)
|
||||
meta = None
|
||||
try:
|
||||
meta = load_transform(os.path.join(standardized_dir, volume_id))
|
||||
except FileNotFoundError:
|
||||
logger.warning(f'{volume_id}: no transform.json '
|
||||
f'({transform_path(os.path.join(standardized_dir, volume_id))}); '
|
||||
f'全部 level 退回重算路徑(近似)')
|
||||
if meta is not None and not np.array_equal(
|
||||
np.asarray(meta['original']['size'], int), n_xyz):
|
||||
logger.warning(f'{volume_id}: transform.json original size '
|
||||
f'{meta["original"]["size"]} != CT {list(n_xyz)}; '
|
||||
f'忽略 transform.json,退回重算路徑')
|
||||
meta = None
|
||||
|
||||
out_arr = np.zeros(n_zyx, np.uint8)
|
||||
lb_img = lb_arr = None # fallback 才需要讀 native label
|
||||
geom_cache = {} # fallback:每 level 的 (R, c, fstart, bbox2s, wx, wy)
|
||||
aff_cache = {} # 主路徑:每 level 的 (M^-1, t)
|
||||
n_screws, skipped = 0, []
|
||||
n_tf = n_fb = 0
|
||||
for li, level in enumerate(LEVELS):
|
||||
for side in ('L', 'R'):
|
||||
jp = os.path.join(side_vol_dir, f'{level}_{side}.json')
|
||||
|
|
@ -192,15 +217,29 @@ def write_volume_cbt(volume_id, run_id, output_root=Output_dir, date=None):
|
|||
continue
|
||||
pos = json.load(open(jp))['position']
|
||||
try:
|
||||
cyl = screw_voxel_xyz(pos)
|
||||
lv = meta['levels'].get(level) if meta is not None else None
|
||||
if lv is not None and 'rotated' in lv:
|
||||
if level not in aff_cache:
|
||||
M, t = original_to_source(meta, level, 'rotated')
|
||||
aff_cache[level] = (np.linalg.inv(M), t)
|
||||
Mi, t = aff_cache[level]
|
||||
nat = (cyl - t) @ Mi.T # (N,3) 原 CT 連續 index
|
||||
n_tf += 1
|
||||
else:
|
||||
if lb_arr is None:
|
||||
lb_img = sitk.ReadImage(lb_path)
|
||||
lb_arr = sitk.GetArrayFromImage(lb_img)
|
||||
if level not in geom_cache:
|
||||
geom_cache[level] = level_geometry(volume_id, level, lb_img, lb_arr)
|
||||
geom = geom_cache[level]
|
||||
cyl = screw_voxel_xyz(pos)
|
||||
nat = rotated_to_native(cyl, geom, N05, sn, ap_flip)
|
||||
nat = rotated_to_native(cyl, geom_cache[level], N05, sn, ap_flip)
|
||||
n_fb += 1
|
||||
ni = np.rint(nat).astype(int)
|
||||
valid = (ni >= 0).all(1) & (ni < np.array(lb_arr.shape[::-1])).all(1)
|
||||
valid = (ni >= 0).all(1) & (ni < n_xyz).all(1)
|
||||
idx = ni[valid]
|
||||
val = li * 2 + (1 if side == 'R' else 0)
|
||||
if idx.shape[0] == 0:
|
||||
raise ValueError('all screw voxels out of original CT bounds')
|
||||
val = li * 2 + 1 + (1 if side == 'R' else 0)
|
||||
out_arr[idx[:, 2], idx[:, 1], idx[:, 0]] = val
|
||||
n_screws += 1
|
||||
except Exception as e:
|
||||
|
|
@ -217,7 +256,8 @@ def write_volume_cbt(volume_id, run_id, output_root=Output_dir, date=None):
|
|||
sitk.WriteImage(out_img, out_path)
|
||||
if skipped:
|
||||
logger.warning(f'{volume_id}: failed sides: {", ".join(skipped)}')
|
||||
logger.info(f'{volume_id}: {n_screws}/10 screws -> {out_path} (ap_flip={ap_flip})')
|
||||
logger.info(f'{volume_id}: {n_screws}/10 screws -> {out_path} '
|
||||
f'(transform.json={n_tf}, re-est={n_fb}, ap_flip={ap_flip})')
|
||||
return out_path, n_screws
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue