128 lines
5 KiB
Python
128 lines
5 KiB
Python
|
|
#!/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()
|