CBT_project/xfr_reprocess_ap.py
Xiao Furen 2ae08ac2cd refactor(imaging): improve orientation detection and segmentation robustness
Refactor the preprocessing and segmentation pipeline to handle AP orientation
variations and improve anatomical boundary detection.

Key changes include:
- Implement automated AP orientation detection in `process_single_image`
  to handle prone scans by flipping CT and labels when necessary.
- Enhance `segment_spinous_process` using a gap-based approach to identify
  the spinal canal, providing more stable thresholds for spinous process
  and vertebral body segmentation.
- Improve optimization search space by using the vertebral body (VBODY)
  projection for x/z bounding box calculation instead of the whole bone.
- Refactor `render_bone_figure` to unify 2D/3D visualization and support
  detailed anatomical coloring (VBODY, spinous process).
- Update `cl_score_torch_xfr` with more robust penalty handling for
  out-of-bone and null-voxel regions.
- Add `retry_robust` utility to handle transient NFS file system errors.
- Update `xfr_preprocess.py` to include anatomical segmentation coloring
  in rotated level visualizations.
2026-09-07 18:46:06 +08:00

128 lines
No EOL
5 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
"""掃 standardize 輸出目錄中「前後AP方向翻轉」的個案prone 伏位掃描,
前側落在 y 小側colon 0003供重跑修正用。
判定:對每個 volume 的 L1~L6 輸出遮罩(優 _binary_sdf、次 _binary_nn、
再 _binary個別跑 orientation.anterior_y_side中線帶椎管兩側質量比較
見該函式 docstring多 level 票決:
flip : y_min 票 > y_max 票(前後翻轉,需重跑)
ok : y_max 票 > y_min 票(方向正常)
mixed : 平手(需人工確認)
unknown : 全部無法判定(無明顯前後質量差,如鏡稱面異常案例)
Usage:
python xfr_reprocess_ap.py <output_dir> # 只回報
python xfr_reprocess_ap.py <output_dir> --fix # 另刪 flip volume 的輸出
# 資料夾 + progress.json
# 條目,之後重跑
# xfr_preprocess.py 即可
"""
import argparse
import json
import os
import shutil
import sys
import time
import SimpleITK as sitk
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from imaging.orientation import anterior_y_side
LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5', 'L6')
MASK_SUFFIXES = ('_binary_sdf.nii.gz', '_binary_nn.nii.gz', '_binary.nii.gz')
def volume_decision(vol_dir):
"""回傳 (decision, per_level dict)。decision ∈ flip/ok/mixed/unknown/nomask。"""
per = {}
for lvl in LEVELS:
for suf in MASK_SUFFIXES:
p = os.path.join(vol_dir, f'{lvl}{suf}')
if os.path.exists(p):
m = sitk.GetArrayFromImage(sitk.ReadImage(p, sitk.sitkUInt8))
per[lvl] = anterior_y_side(m)
break
if not per:
return 'nomask', per
votes = [v for v in per.values() if v is not None]
if not votes:
return 'unknown', per
n_min = votes.count('y_min')
n_max = votes.count('y_max')
if n_min > n_max:
return 'flip', per
if n_max > n_min:
return 'ok', per
return 'mixed', per
def main():
parser = argparse.ArgumentParser(
description='Find AP-flipped (prone) volumes in a standardized output dir.')
parser.add_argument('output_dir')
parser.add_argument('--fix', action='store_true',
help='Also delete flipped volumes\' output dirs and '
'their progress.json entries')
args = parser.parse_args()
outdir = args.output_dir
if not os.path.isdir(outdir):
print(f'not a directory: {outdir}')
return
vols = sorted(d for d in os.listdir(outdir)
if os.path.isdir(os.path.join(outdir, d)))
flip, mixed, unknown, ok, nomask = [], [], [], [], []
t0 = time.time()
for i, vol in enumerate(vols, 1):
dec, per = volume_decision(os.path.join(outdir, vol))
tag = {'flip': 'FLIP', 'ok': 'ok ', 'mixed': 'MIXED',
'unknown': '?!?', 'nomask': '- '}[dec]
detail = ' '.join(f'{k}={v}' for k, v in per.items())
print(f'[{i}/{len(vols)}] {tag} {vol} {detail}')
{'flip': flip, 'mixed': mixed, 'unknown': unknown,
'ok': ok, 'nomask': nomask}[dec].append(vol)
if (i % 25) == 0:
print(f' ... {i}/{len(vols)} ({(time.time()-t0)/60:.1f} min)', flush=True)
print(f'\n=== Summary: {len(vols)} volumes ===')
print(f' ok (normal) : {len(ok)}')
print(f' FLIP (AP-flipped) : {len(flip)}')
for v in flip:
print(f' - {v}')
print(f' mixed (need check) : {len(mixed)}')
for v in mixed:
print(f' - {v}')
print(f' unknown (no vote) : {len(unknown)}')
for v in unknown:
print(f' - {v}')
print(f' no level mask : {len(nomask)}')
if args.fix and flip:
# 1) progress.json刪掉 flip 的條目(備份)
prog_path = os.path.join(outdir, 'progress.json')
if os.path.exists(prog_path):
with open(prog_path) as f:
prog = json.load(f)
removed = [v for v in flip if prog.pop(v, None) is not None]
bak = f'{prog_path}.apfix-{time.strftime("%Y%m%d_%H%M%S")}'
shutil.copyfile(prog_path, bak)
with open(prog_path, 'w') as f:
json.dump(prog, f, indent=2)
print(f'\nprogress.json: removed {len(removed)} entr(y/ies) '
f'[{", ".join(v.split(".")[-1] or v for v in removed)}]; '
f'backup {bak}')
# 2) 刪輸出資料夾
for v in flip:
shutil.rmtree(os.path.join(outdir, v))
print(f'removed {os.path.join(outdir, v)}')
print('\nNext: rerun `python xfr_preprocess.py` — only the removed '
'volumes will be reprocessed (with the AP flip applied).')
elif not args.fix and flip:
print('\nRerun with --fix to delete the flipped outputs and progress '
'entries, then run `python xfr_preprocess.py`.')
if __name__ == '__main__':
main()