From b38a06dcbe194b2ee8ba2612e63e94f2bfb8e0e6 Mon Sep 17 00:00:00 2001 From: rudals252 Date: Tue, 2 Dec 2025 11:57:45 +0900 Subject: [PATCH] =?UTF-8?q?add=20:=20dcm=20/=20nii.gz=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=20/=20cvat=20=EC=BB=A8=EB=B2=84=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DCM_NII_to_CVAT.py | 171 ++++++++++++++++++++++++++++++++++++++++++ NII_DCM_Viewer.py | 182 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 49 ++++++++++++ requirements.txt | 6 ++ 4 files changed, 408 insertions(+) create mode 100644 DCM_NII_to_CVAT.py create mode 100644 NII_DCM_Viewer.py create mode 100644 README.md create mode 100644 requirements.txt diff --git a/DCM_NII_to_CVAT.py b/DCM_NII_to_CVAT.py new file mode 100644 index 0000000..890c3b2 --- /dev/null +++ b/DCM_NII_to_CVAT.py @@ -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() diff --git a/NII_DCM_Viewer.py b/NII_DCM_Viewer.py new file mode 100644 index 0000000..62dee58 --- /dev/null +++ b/NII_DCM_Viewer.py @@ -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'{content}') + 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 파일이 없습니다.") diff --git a/README.md b/README.md new file mode 100644 index 0000000..68a311d --- /dev/null +++ b/README.md @@ -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`: 필요 라이브러리 목록 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8efa702 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +nibabel +numpy +matplotlib +pydicom +opencv-python +Pillow \ No newline at end of file