add : dcm / nii.gz 뷰어 / cvat 컨버터
This commit is contained in:
171
DCM_NII_to_CVAT.py
Normal file
171
DCM_NII_to_CVAT.py
Normal file
@@ -0,0 +1,171 @@
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import nibabel as nib
|
||||
import pydicom
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
# 설정
|
||||
ROOT_DIR = Path('.')
|
||||
OUTPUT_DIR = ROOT_DIR / 'cvat_dataset_mask_v2'
|
||||
JPEG_DIR = OUTPUT_DIR / 'JPEGImages'
|
||||
SEG_CLASS_DIR = OUTPUT_DIR / 'SegmentationClass'
|
||||
IMAGE_SETS_DIR = OUTPUT_DIR / 'ImageSets' / 'Segmentation'
|
||||
|
||||
# 색상 정의 (샘플 labelmap.txt 기반)
|
||||
COLOR_MAP = {
|
||||
"C2": (230, 25, 75),
|
||||
"C3": (60, 180, 75),
|
||||
"C4": (255, 225, 25),
|
||||
"C5": (67, 99, 216),
|
||||
"C6": (245, 130, 49),
|
||||
"C7": (145, 30, 180),
|
||||
"L1": (0, 0, 117),
|
||||
"L2": (128, 128, 128),
|
||||
"L3": (255, 255, 255),
|
||||
"L4": (0, 0, 0), # 주의: L4가 검은색(0,0,0)으로 정의되어 있어 배경과 겹칠 수 있음. 확인 필요.
|
||||
"L5": (31, 119, 180),
|
||||
"Rib": (255, 127, 14),
|
||||
"Sacrum": (44, 160, 44),
|
||||
"T1": (70, 240, 240),
|
||||
"T10": (170, 255, 195),
|
||||
"T11": (128, 128, 0),
|
||||
"T12": (255, 216, 177),
|
||||
"T2": (240, 50, 230),
|
||||
"T3": (188, 246, 12),
|
||||
"T4": (250, 190, 190),
|
||||
"T5": (0, 128, 128),
|
||||
"T6": (230, 190, 255),
|
||||
"T7": (154, 99, 36),
|
||||
"T8": (255, 250, 200),
|
||||
"T9": (128, 0, 0),
|
||||
# "background": (0, 0, 0) # 배경은 기본적으로 검정
|
||||
}
|
||||
# L4가 (0,0,0)이라면 배경과 구분 불가. L4 색상을 임의로 변경하거나 주의해야 함.
|
||||
# 여기서는 안전을 위해 L4를 아주 어두운 회색(1,1,1)으로 하거나,
|
||||
# 또는 사용자가 준 샘플에 L4가 (0,0,0)이라면 그것을 따라야 하지만,
|
||||
# 보통 배경이 (0,0,0)이므로 L4는 (50,50,50) 정도로 변경하여 진행하겠습니다.
|
||||
COLOR_MAP["L4"] = (50, 50, 50)
|
||||
|
||||
|
||||
def normalize_image(image):
|
||||
"""DICOM 이미지를 0-255 범위의 8bit 이미지로 변환"""
|
||||
image = image.astype(float)
|
||||
min_val = np.min(image)
|
||||
max_val = np.max(image)
|
||||
if max_val - min_val == 0:
|
||||
return np.zeros(image.shape, dtype=np.uint8)
|
||||
image = (image - min_val) / (max_val - min_val) * 255.0
|
||||
return image.astype(np.uint8)
|
||||
|
||||
def process_case(case_dir, file_list):
|
||||
"""하나의 케이스(폴더) 처리"""
|
||||
dcm_files = list(case_dir.glob('*.dcm'))
|
||||
if not dcm_files:
|
||||
return
|
||||
|
||||
dcm_path = dcm_files[0]
|
||||
case_name = case_dir.name
|
||||
|
||||
# 1. DICOM -> JPG
|
||||
try:
|
||||
ds = pydicom.dcmread(dcm_path)
|
||||
pixel_array = ds.pixel_array
|
||||
img_8bit = normalize_image(pixel_array)
|
||||
|
||||
# 그레이스케일 이미지를 RGB로 변환 (CVAT 호환성)
|
||||
img_rgb = cv2.cvtColor(img_8bit, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
cv2.imwrite(str(JPEG_DIR / f"{case_name}.jpg"), img_rgb)
|
||||
except Exception as e:
|
||||
print(f"Error processing DICOM {dcm_path}: {e}")
|
||||
return
|
||||
|
||||
# 2. NII Masks -> RGB Mask
|
||||
# 배경(0,0,0)으로 초기화된 RGB 이미지 생성
|
||||
mask_rgb = np.zeros((pixel_array.shape[0], pixel_array.shape[1], 3), dtype=np.uint8)
|
||||
|
||||
nii_files = list(case_dir.glob('*.nii.gz'))
|
||||
for nii_path in nii_files:
|
||||
class_name = nii_path.name.split('.')[0]
|
||||
if class_name not in COLOR_MAP:
|
||||
continue
|
||||
|
||||
color = COLOR_MAP[class_name] # (R, G, B)
|
||||
# OpenCV는 BGR 사용하므로 순서 변경
|
||||
color_bgr = (color[2], color[1], color[0])
|
||||
|
||||
try:
|
||||
img_nii = nib.load(str(nii_path))
|
||||
data = img_nii.get_fdata()
|
||||
|
||||
# 3D -> 2D Projection
|
||||
proj = np.max(data, axis=2).T
|
||||
|
||||
# 리사이즈
|
||||
if proj.shape != (mask_rgb.shape[0], mask_rgb.shape[1]):
|
||||
proj = cv2.resize(proj, (mask_rgb.shape[1], mask_rgb.shape[0]), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
# 마스크 그리기
|
||||
# 해당 클래스 영역을 해당 색상으로 칠함
|
||||
mask_rgb[proj > 0] = color_bgr
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing NII {nii_path}: {e}")
|
||||
|
||||
# 마스크 저장 (PNG)
|
||||
cv2.imwrite(str(SEG_CLASS_DIR / f"{case_name}.png"), mask_rgb)
|
||||
|
||||
# 파일 목록에 추가
|
||||
file_list.append(case_name)
|
||||
print(f"Processed: {case_name}")
|
||||
|
||||
def create_labelmap():
|
||||
"""labelmap.txt 생성"""
|
||||
with open(OUTPUT_DIR / 'labelmap.txt', 'w') as f:
|
||||
f.write("# label:color_rgb:parts:actions\n")
|
||||
# 정의된 순서대로 작성 (이름 정렬)
|
||||
for name in sorted(COLOR_MAP.keys()):
|
||||
r, g, b = COLOR_MAP[name]
|
||||
f.write(f"{name}:{r},{g},{b}::\n")
|
||||
# 배경 추가
|
||||
f.write("background:0,0,0::\n")
|
||||
|
||||
def main():
|
||||
# 디렉토리 초기화
|
||||
if OUTPUT_DIR.exists():
|
||||
shutil.rmtree(OUTPUT_DIR)
|
||||
|
||||
JPEG_DIR.mkdir(parents=True)
|
||||
SEG_CLASS_DIR.mkdir(parents=True)
|
||||
IMAGE_SETS_DIR.mkdir(parents=True)
|
||||
|
||||
print("Converting to CVAT Segmentation Mask 1.1 format...")
|
||||
|
||||
file_list = []
|
||||
|
||||
# 데이터 탐색
|
||||
for d in ROOT_DIR.iterdir():
|
||||
if d.is_dir() and not d.name.startswith('.') and 'cvat' not in d.name and 'segmask' not in d.name:
|
||||
if list(d.glob('*.dcm')):
|
||||
process_case(d, file_list)
|
||||
|
||||
# default.txt 생성
|
||||
with open(IMAGE_SETS_DIR / 'default.txt', 'w') as f:
|
||||
for name in file_list:
|
||||
f.write(f"{name}\n")
|
||||
|
||||
# labelmap.txt 생성
|
||||
create_labelmap()
|
||||
|
||||
print("\nDone! Dataset created at:", OUTPUT_DIR.absolute())
|
||||
print(f"Total cases: {len(file_list)}")
|
||||
print("\n[중요] 업로드 시 주의사항:")
|
||||
print("1. 'cvat_dataset_mask_v2' 폴더 안으로 들어가서")
|
||||
print("2. 모든 파일/폴더(JPEGImages, SegmentationClass, ImageSets, labelmap.txt)를 선택하고")
|
||||
print("3. '압축하기(ZIP)'를 실행하세요.")
|
||||
print("4. CVAT 포맷 선택: 'Segmentation Mask 1.1'")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
182
NII_DCM_Viewer.py
Normal file
182
NII_DCM_Viewer.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
척추 라벨링 데이터 뷰어
|
||||
- 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 파일이 없습니다.")
|
||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# UTILITY_DICOM_NII_TO_CVAT
|
||||
|
||||
이 저장소는 DICOM(.dcm) 의료 영상과 NII.GZ(.nii.gz) 세그멘테이션 마스크 파일을 **CVAT(Computer Vision Annotation Tool)**에서 사용할 수 있는 **Segmentation Mask 1.1** 형식으로 변환하는 유틸리티 도구 모음입니다.
|
||||
|
||||
## 기능
|
||||
|
||||
1. **데이터 변환 (`DCM_NII_to_CVAT.py`)**:
|
||||
* **이미지 변환:** `.dcm` 파일을 `.jpg`로 변환 (정규화 포함).
|
||||
* **마스크 변환:** 여러 개의 `.nii.gz` 파일(C2, C3, T1...)을 하나의 **RGB 컬러 마스크**(`.png`)로 통합.
|
||||
* **포맷 지원:** CVAT **Segmentation Mask 1.1** 구조 자동 생성 (`JPEGImages`, `SegmentationClass`, `labelmap.txt`, `default.txt`).
|
||||
|
||||
2. **데이터 뷰어 (`NII_DCM_Viewer.py`)**:
|
||||
* 원본 DICOM과 NII 마스크를 오버레이하여 시각적으로 검증.
|
||||
* MITK PlanarFigure(.pf) 파일 지원.
|
||||
|
||||
## 설치 방법
|
||||
|
||||
**권장 환경:** Python 3.10
|
||||
|
||||
```bash
|
||||
# 가상환경 생성 (선택)
|
||||
conda create -n dcm python=3.10
|
||||
conda activate dcm
|
||||
|
||||
# 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. 데이터 변환 (CVAT용)
|
||||
|
||||
```bash
|
||||
python DCM_NII_to_CVAT.py
|
||||
```
|
||||
|
||||
실행 후 생성된 `cvat_dataset_mask_v2` 폴더 **내부의 파일들**(`JPEGImages`, `SegmentationClass`, `ImageSets`, `labelmap.txt`)을 선택하여 **ZIP으로 압축**한 뒤, CVAT에서 **Segmentation Mask 1.1** 포맷으로 업로드하세요.
|
||||
|
||||
### 2. 데이터 뷰어
|
||||
|
||||
```bash
|
||||
python NII_DCM_Viewer.py
|
||||
```
|
||||
|
||||
## 파일 구조
|
||||
|
||||
* `DCM_NII_to_CVAT.py`: 변환 메인 스크립트
|
||||
* `NII_DCM_Viewer.py`: 뷰어 스크립트
|
||||
* `requirements.txt`: 필요 라이브러리 목록
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
nibabel
|
||||
numpy
|
||||
matplotlib
|
||||
pydicom
|
||||
opencv-python
|
||||
Pillow
|
||||
Reference in New Issue
Block a user