183 lines
5.5 KiB
Python
183 lines
5.5 KiB
Python
"""
|
|
척추 라벨링 데이터 뷰어
|
|
- NII.GZ: 3D 세그멘테이션 마스크
|
|
- PF: MITK PlanarFigure (2D 측정)
|
|
- DCM: DICOM 원본 이미지
|
|
"""
|
|
|
|
import nibabel as nib
|
|
import numpy as np
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.widgets import Slider
|
|
from matplotlib.lines import Line2D
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
import re
|
|
|
|
ROOT_PATH = Path(__file__).parent
|
|
BASE_PATH = None
|
|
DCM_FILE = None
|
|
MITK_ORIGIN_Y = 966.81
|
|
SPACING = 0.148
|
|
|
|
|
|
def load_nii(filepath):
|
|
img = nib.load(filepath)
|
|
return img.get_fdata(), img.header
|
|
|
|
|
|
def load_dcm(filepath):
|
|
import pydicom
|
|
dcm = pydicom.dcmread(filepath)
|
|
return dcm.pixel_array
|
|
|
|
|
|
def parse_pf(filepath):
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
content = re.sub(r'<\?xml[^?]*\?>', '', content)
|
|
root = ET.fromstring(f'<root>{content}</root>')
|
|
pf = root.find('PlanarFigure')
|
|
|
|
return {
|
|
'type': pf.get('type'),
|
|
'vertices': [(float(v.get('x')), float(v.get('y'))) for v in pf.findall('.//Vertex')]
|
|
}
|
|
|
|
|
|
def pf_to_pixel(x, y):
|
|
"""MITK 좌표 -> 픽셀 좌표 변환"""
|
|
return x / SPACING, (MITK_ORIGIN_Y - y) / SPACING
|
|
|
|
|
|
def draw_pf(ax, pf_files, colors):
|
|
labels = []
|
|
for i, pf_file in enumerate(pf_files):
|
|
data = parse_pf(str(pf_file))
|
|
color = colors[i % len(colors)]
|
|
transformed = [pf_to_pixel(x, y) for x, y in data['vertices']]
|
|
|
|
if 'Line' in data['type']:
|
|
xs, ys = zip(*transformed)
|
|
ax.plot(xs, ys, '-o', color=color, linewidth=3, markersize=8)
|
|
elif 'Circle' in data['type'] and len(transformed) >= 2:
|
|
center, edge = transformed[0], transformed[1]
|
|
radius = np.hypot(edge[0]-center[0], edge[1]-center[1])
|
|
ax.add_patch(plt.Circle(center, radius, fill=False, color=color, linewidth=3))
|
|
ax.plot(center[0], center[1], 'o', color=color, markersize=8)
|
|
|
|
labels.append((color, pf_file.stem))
|
|
return labels
|
|
|
|
|
|
def view_nii_overlay():
|
|
"""모든 NII 마스크를 색상별로 겹쳐서 표시"""
|
|
nii_files = list(BASE_PATH.glob('*.nii.gz'))
|
|
first_data, _ = load_nii(str(nii_files[0]))
|
|
combined = np.zeros(first_data.shape)
|
|
|
|
for i, f in enumerate(nii_files, 1):
|
|
data, _ = load_nii(str(f))
|
|
combined[data > 0] = i
|
|
|
|
fig, ax = plt.subplots(figsize=(10, 10))
|
|
mid_z = combined.shape[2] // 2
|
|
|
|
slider_ax = plt.axes([0.2, 0.02, 0.6, 0.03])
|
|
slider = Slider(slider_ax, 'Slice', 0, combined.shape[2]-1, valinit=mid_z, valstep=1)
|
|
|
|
plt.sca(ax)
|
|
img_plot = ax.imshow(combined[:, :, mid_z].T, cmap='nipy_spectral', origin='lower')
|
|
ax.set_title(f'All Vertebrae - Slice {mid_z}')
|
|
plt.colorbar(img_plot, ax=ax)
|
|
|
|
def update(val):
|
|
idx = int(slider.val)
|
|
img_plot.set_data(combined[:, :, idx].T)
|
|
ax.set_title(f'All Vertebrae - Slice {idx}')
|
|
fig.canvas.draw_idle()
|
|
|
|
slider.on_changed(update)
|
|
plt.show()
|
|
|
|
|
|
def view_all_on_dcm():
|
|
"""DCM + NII 마스크 + PF 측정 오버레이"""
|
|
img = load_dcm(str(DCM_FILE))
|
|
nii_files = list(BASE_PATH.glob('*.nii.gz'))
|
|
pf_files = list(BASE_PATH.glob('*.pf'))
|
|
|
|
fig, ax = plt.subplots(figsize=(10, 20))
|
|
ax.imshow(img, cmap='gray', origin='upper')
|
|
|
|
# NII 마스크 컨투어
|
|
cmap = plt.colormaps.get_cmap('tab20').resampled(len(nii_files))
|
|
nii_labels = []
|
|
for i, f in enumerate(nii_files):
|
|
data, _ = load_nii(str(f))
|
|
proj = np.max(data, axis=2).T # (W,H,D) -> (H,W)
|
|
if proj.max() > 0:
|
|
ax.contour(proj, levels=[0.5], colors=[cmap(i)], linewidths=1.5, alpha=0.8)
|
|
nii_labels.append((cmap(i), f.stem.split('.')[0]))
|
|
|
|
# PF 오버레이
|
|
pf_labels = draw_pf(ax, pf_files, ['red', 'yellow', 'cyan', 'lime', 'magenta'])
|
|
|
|
# 범례
|
|
legend = [Line2D([0], [0], color=c, label=n) for c, n in nii_labels]
|
|
legend += [Line2D([0], [0], color=c, marker='o', label=f'[PF] {n}') for c, n in pf_labels]
|
|
ax.legend(handles=legend, loc='upper left', bbox_to_anchor=(1.02, 1), fontsize=8)
|
|
ax.set_title('DCM + NII Masks + PF Annotations')
|
|
plt.tight_layout()
|
|
plt.show()
|
|
|
|
|
|
def select_directory():
|
|
"""디렉토리 선택"""
|
|
global BASE_PATH, DCM_FILE
|
|
dirs = sorted([d for d in ROOT_PATH.iterdir() if d.is_dir() and not d.name.startswith('.')])
|
|
|
|
if not dirs:
|
|
print("하위 디렉토리가 없습니다.")
|
|
return False
|
|
|
|
print("\n=== 디렉토리 선택 ===")
|
|
for i, d in enumerate(dirs, 1):
|
|
print(f"{i}. {d.name}")
|
|
|
|
try:
|
|
idx = int(input(f"\n선택 (1-{len(dirs)}): ").strip()) - 1
|
|
if 0 <= idx < len(dirs):
|
|
BASE_PATH = dirs[idx]
|
|
dcm_files = list(BASE_PATH.glob('*.dcm'))
|
|
DCM_FILE = dcm_files[0] if dcm_files else None
|
|
print(f"선택됨: {BASE_PATH.name}")
|
|
return True
|
|
except (ValueError, IndexError):
|
|
pass
|
|
print("잘못된 선택")
|
|
return False
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print("=== 척추 라벨링 뷰어 ===")
|
|
if not select_directory():
|
|
exit()
|
|
|
|
while True:
|
|
print(f"\n=== {BASE_PATH.name} ===")
|
|
print("1. NII 마스크 오버레이 (슬라이더)")
|
|
print("2. DCM + NII + PF 전체 오버레이")
|
|
print("q. 종료")
|
|
|
|
choice = input("\n선택: ").strip().lower()
|
|
if choice == 'q':
|
|
break
|
|
elif choice == '1':
|
|
view_nii_overlay()
|
|
elif choice == '2':
|
|
if DCM_FILE:
|
|
view_all_on_dcm()
|
|
else:
|
|
print("DCM 파일이 없습니다.")
|