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:
xfr 2026-09-09 19:08:52 +08:00
parent d167c1f7c7
commit 0a928d8f8e

View file

@ -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_sourceboxes / std_flip_axes / ap_flip /
rotated Rcenterstart / CT 幾何 index 空間不依賴 standardized
輸出的物理 header該鏈為仿射original index <-> rotated disk index
螺絲點經仿射逆向 o = M^-1 (s - t) 精確映射不重新估算任何平面 / 幾何
不重取樣 native label
其中每 level R/c template 骨頭 masksmd_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/centertemplate 骨頭 mask
best_symmetry_plane / best_upper_endplate_planefstartrotated
origin 反映到 template index 的整數bbox2native 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 indexpos = [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-09transform.json 路徑20260909_141448 run
000110/10liver_10010/10covid19L1/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