CBT_project/xfr_record_planes.py
Xiao Furen 332226f843 Add pixi.toml configuration for project dependencies and tasks
- Created a new pixi.toml file to manage project dependencies and tasks.
- Defined workspace metadata including authors, channels, name, platforms, and version.
- Specified tasks for preprocessing, PSO, DE, and other scripts.
- Listed dependencies for Python and various libraries with specific versions.
- Configured additional PyPI options for PyTorch installation from a custom index.
2026-10-19 20:34:28 +08:00

214 lines
No EOL
8.3 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.

#!/usr/bin/env python
"""補記既有 volume 的 transform.json rotated 段平面(sym / endplate)。
core/optimizer 的 xfr 管線(run_pso_torch_xfr / run_de_torch_xfr)不再於影像
上重新估算兩平面(best_symmetry_plane / best_upper_endplate_plane),改直接
讀 <vol>/transform.json levels[L*]['rotated']['sym' / 'endplate']
(imaging.transforms.load_rotated_planes_from_path):
- 新世代:xfr_preprocess._write_rotated_level 旋轉時即記錄(rotated/ 局部系);
- 舊世代(進度 progress.json 已標完成、重跑 preprocess 會整支跳過):跑本腳本
補記——用與舊 optimizer 相同函式、相同預設值,對
rotated/<level>_binary_sdf.nii.gz(完整 mask、SP 移除前)重新估算,
併入既有 rotated 段(只加 sym / endplate 兩鍵,不動 template / R / center /
start / size 等既有鍵、不重寫任何 nii)。補記後 optimizer 讀到的平面與
改前行為一致(同輸入、同演算法)。
預設跳過已有 sym 記錄的 level(部分失敗後重跑只補缺者);--force 全數重記。
Usage:
python xfr_record_planes.py [--force] [--dir <standardized dir>] [volume_id ...]
volume_id 可用完整 ID 或末段(如 0001);省略 = 全部 volume。
"""
import argparse
import logging
import os
import re
import sys
import time
import numpy as np
import SimpleITK as sitk
from imaging.orientation import best_symmetry_plane, best_upper_endplate_plane
from imaging.transforms import load_transform, save_transform
STANDARDIZED_DIR = '/mnt/1248/open2/cyrou/CBT/Seg/Resample/standardized-xfr-3/'
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
logger = logging.getLogger('xfr_record_planes')
# 基準系健全閾值:對齊良好時 theta_v / tau_y 應近似 0(xfr_preprocess 的 R
# 已把鏡稱面法線轉到 +x、終板法線轉到 z>0)
SANITY_DEG = 5.0
class _Tee:
"""同時輸出到 console 與 log 檔,逐行寫入。"""
def __init__(self, console, log_fh):
self.console = console
self.log_fh = log_fh
self.buf = ''
def _emit(self, line):
self.console.write(line + '\n')
self.log_fh.write(line + '\n')
def write(self, data):
if not data:
return
self.buf += data
while True:
idx_n = self.buf.find('\n')
idx_r = self.buf.find('\r')
candidates = [i for i in (idx_n, idx_r) if i != -1]
if not candidates:
break
idx = min(candidates)
self._emit(self.buf[:idx])
self.buf = self.buf[idx + 1:]
def flush(self):
if self.buf:
self._emit(self.buf)
self.buf = ''
self.console.flush()
self.log_fh.flush()
def isatty(self):
return False
def setup_tee(log_path):
log_fh = open(log_path, 'a', buffering=1)
sys.stdout = _Tee(sys.stdout, log_fh)
sys.stderr = _Tee(sys.stderr, log_fh)
def rotated_levels(vol_dir):
"""該 volume rotated/ 中有 _binary_sdf.nii.gz 的 lumbar level(排序)。"""
rotated_dir = os.path.join(vol_dir, 'rotated')
if not os.path.isdir(rotated_dir):
return ()
levels = set()
for fn in os.listdir(rotated_dir):
m = re.fullmatch(r'(L[1-9]\d*)_binary_sdf\.nii\.gz', fn)
if m:
levels.add(m.group(1))
return tuple(sorted(levels, key=lambda lv: int(lv[1:])))
def record_level(vol_dir, level, sdf_path):
"""估算該 level 兩平面併入 transform.json rotated 段;回傳 (sym, endplate)。"""
arr = sitk.GetArrayFromImage(sitk.ReadImage(sdf_path))
sym = best_symmetry_plane(arr)
endplate = best_upper_endplate_plane(arr)
meta = load_transform(vol_dir)
lv = meta['levels'].setdefault(level, {})
rot = lv.setdefault('rotated', {})
rot['sym'] = sym
rot['endplate'] = endplate
save_transform(vol_dir, meta)
s_nx, s_ny, _ = sym['normal']
theta_v = float(np.degrees(np.arctan2(s_ny, s_nx)))
if endplate is not None:
e_nx, e_ny, e_nz = endplate['normal']
tau_y = float(np.degrees(np.arctan2(e_ny, e_nz)))
else:
tau_y = None
note = f'endplate={endplate is not None}'
if abs(theta_v) > SANITY_DEG or (tau_y is not None and abs(tau_y) > SANITY_DEG):
logger.warning(f'{os.path.basename(vol_dir)} {level}: 基準系殘差偏大 '
f'(theta_v={theta_v:+.2f} deg, tau_y='
f'{tau_y if tau_y is None else round(tau_y, 2)}) — '
f'該 level 的 xfr_preprocess 對齊可能失效,建議人工核對')
else:
logger.info(f'{os.path.basename(vol_dir)} {level}: recorded '
f'(mirror ratio={sym["ratio"]:.3f}, theta_v={theta_v:+.2f} deg, '
f'tau_y={tau_y if tau_y is None else round(tau_y, 2)}, {note})')
return sym, endplate
def main():
parser = argparse.ArgumentParser(
description='Record sym/endplate planes into transform.json for '
'existing volumes (old generation, before xfr_preprocess '
'recording them at rotation time).')
parser.add_argument('volume_ids', nargs='*',
help='Full ID or trailing segment (e.g. 0001); '
'default: all volumes')
parser.add_argument('--dir', default=STANDARDIZED_DIR,
help='Standardized output dir (default: standardized-xfr-3)')
parser.add_argument('--force', action='store_true',
help='Re-record levels that already have sym/endplate')
args = parser.parse_args()
os.makedirs(LOG_DIR, exist_ok=True)
log_path = os.path.join(
LOG_DIR, f'xfr_record_planes_{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)}')
if not os.path.isdir(args.dir):
sys.exit(f'Standardized dir not found: {args.dir}')
volumes = [d for d in sorted(os.listdir(args.dir))
if os.path.isdir(os.path.join(args.dir, d))]
if args.volume_ids:
keys = [v.lower() for v in args.volume_ids]
vols = [v for v in volumes
if v.lower() in keys or v.rsplit('.', 1)[-1].lower() in keys]
if not vols:
sys.exit(f'No volume matches: {", ".join(args.volume_ids)}')
volumes = vols
logger.info(f'{len(volumes)} volume(s) under {args.dir}'
+ (' (force)' if args.force else ''))
t0 = time.time()
n_rec, n_skip_done, n_err, n_no_levels = 0, 0, 0, 0
no_ep = []
for vid in volumes:
vol_dir = os.path.join(args.dir, vid)
levels = rotated_levels(vol_dir)
if not levels:
n_no_levels += 1
continue
try:
meta = load_transform(vol_dir)
except FileNotFoundError:
logger.warning(f'{vid}: transform.json 缺失,跳過 '
f'(舊世代可先在 xfr_inverse_transform.py 加 --rebuild)')
n_err += 1
continue
for level in levels:
rot = meta['levels'].get(level, {}).get('rotated') or {}
if not args.force and rot.get('sym') is not None:
n_skip_done += 1
continue
sdf_path = os.path.join(vol_dir, 'rotated', f'{level}_binary_sdf.nii.gz')
try:
_, ep = record_level(vol_dir, level, sdf_path)
n_rec += 1
if ep is None:
no_ep.append(f'{vid} {level}')
except Exception as e:
n_err += 1
logger.error(f'{vid} {level}: {e}')
dt = time.time() - t0
logger.info(f'Done in {dt / 60:.1f} min: recorded {n_rec} level(s), '
f'skipped(already) {n_skip_done}, no rotated levels '
f'{n_no_levels} volume(s), errors {n_err}')
if no_ep:
logger.warning(f'{len(no_ep)} level(s) 無上終板面記錄(資料不足),'
f'optimizer 對應 level 終板入口剪除停用: {", ".join(no_ep[:10])}'
f'{" ..." if len(no_ep) > 10 else ""}')
if __name__ == '__main__':
main()