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_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:
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)
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)
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