[학습 일기 # 39] MONAI_Part 1

Ariel_Jeong·2026년 3월 11일

[학습 일기 시리즈]

목록 보기
40/44

MONAI(Medical Open Network for AI)의료영상 AI를 위한 PyTorch 기반 오픈소스 프레임워크로,
일반 딥러닝 프레임워크 위에 의료영상에 특화된 transform, 데이터 처리, 모델 개발 흐름
얹어 놓은 도구라고 이해하면 좋다.




1. Intro 🩻

일반 이미지 AI는 고양이, 강아지, 자동차, 사람 얼굴처럼 일상 이미지를 다루는 경우가 많다.
반면 Medical Imaging AI는 X-ray, CT, MRI, 초음파처럼 인체 내부 구조를 찍은 영상을 다룬다.

여기서 중요한 차이가 생긴다.

일반 이미지에서는 “무엇이 있는가?”가 핵심이라면,
의료영상에서는 그 질문이 더 확장된다.

  • 무엇이 있는가?
  • 어디에 있는가?
  • 얼마나 큰가?
  • 다른 조직과 어떤 공간적 관계를 가지는가?

즉, 의료영상에서는 단순 분류뿐 아니라 위치와 형태를 함께 이해하는 문제가 훨씬 많다.

그래서 단순 classification보다
segmentation(분할), detection(탐지), registration(정합/위치 맞춤) 같은 작업이 자주 등장한다.


🧐 2D image와 3D medical image의 차이?

2D image

2D 이미지는 높이와 너비만 있는 평면 데이터다.
예를 들어 일반 사진 한 장, X-ray 한 장은 보통 2D 이미지로 다룬다.

PyTorch 기준으로 딥러닝 입력은 보통 (채널(C), 높이(H), 너비(W)) 형태를 쓴다.
예를 들어 grayscale 이미지 한 장이면 (1, H, W), RGB 이미지면 (3, H, W)다.

3D medical image

CT나 MRI는 보통 한 장의 사진이 아니라, 얇은 단면들이 여러 장 쌓인 입체 데이터다.
이걸 3D volume data라고 부른다.

쉽게 비유하자면:

  • 2D image = 종이 한 장
  • 3D volume = 종이를 여러 장 층층이 쌓은 책

원본 NIfTI를 nibabel로 읽으면 대개 (H, W, D)처럼 보인다. (아닐수도 있어서 미리 확인해야한다!)
여기서 D는 color channel이 아니라 깊이(depth), 즉 slice가 쌓인 축이다.

그리고 딥러닝에 넣기 위해 EnsureChannelFirstd를 적용하면
(1, H, W, D)처럼 바뀐다.
즉, 원래 없던 채널축을 하나 추가해서 모델 입력 형식에 맞추는 것이다.

이 지점이 처음엔 가장 많이 헷갈린다.

  • 2D RGB 사진의 C는 색상 채널
  • 3D CT의 마지막 축 D는 깊이 방향 slice
  • MONAI 처리 후 맨 앞의 C=1은 모델 입력용 채널축

이 셋은 서로 역할이 다르다.


Classification과 Segmentation의 차이?

간단히 표현하자면,

  • Classification: 이 이미지 전체가 무엇인지 맞히는 문제
  • Segmentation: 이 이미지 안에서 어디가 무엇인지 맞히는 문제

자세한 설명은 이전 포스팅을 참고 바란다.

우리는 이번 실습에서 CT에서 비장(spleen)이 어디에 있는지
픽셀/voxel 단위로 표시
하는 것이 task였기에 segmentation에 해당한다.


NIfTI 파일은 단순한 이미지 파일이 아니다

의료영상에서 자주 보는 확장자가 .nii, .nii.gz다.
이 형식을 'NIfTI'라고 부른다.

중요한 점은 NIfTI를 “그림 파일”처럼만 보면 안 된다는 것이다.

nibabel 문서 기준으로 NIfTI image object는 크게 세 가지로 이해할 수 있다.

  • 영상 데이터 배열
  • 배열 좌표를 실제 공간 좌표에 연결하는 affine
  • 영상에 대한 구조적 설명이 담긴 header

즉, NIfTI는 단순히 픽셀 숫자만 저장하는 파일이 아니라,
“이미지 + 좌표 정보 + 설명서”가 함께 들어 있는 파일이다.

⭐️ 이걸 비유하면:

  • image array = 실제 사진 내용
  • affine = 지도 좌표 변환표
  • header = 포장 박스에 붙은 제품 사양표

그래서 의료영상 AI에서는 파일을 읽을 때 “숫자 배열만 꺼내면 끝”이 아니라,
그 배열이 실제 몸속 어느 방향, 어느 크기, 어느 간격을 뜻하는지도 함께 봐야 한다.




2. 용어 정리 ⭐️

  • pixel

    • 2D 이미지의 최소 단위.
    • 사진에서는 작은 점 하나하나가 pixel이다.
  • Voxel 🧊

    • volumetric pixel의 줄임말.
    • 즉, 3D 이미지의 최소 단위다.
    • pixel이 “평면 위 점”이라면, voxel은 “작은 부피를 가진 큐브”에 가깝다.
    • CT나 MRI는 이 voxel들이 3차원 공간에 촘촘히 쌓여 있는 구조라고 볼 수 있다.
  • Header

    • 데이터 설명서.
    • 여기에는 영상의 shape, voxel spacing(축척), 데이터 타입, 방향 관련 정보 등이 담긴다.
    • 즉, 모델 입력 그 자체는 아니지만,
      이 영상을 어떻게 해석해야 하는지 알려주는 정보다.
  • Affine

    • 배열 인덱스를 실제 공간 좌표에 연결하는 4×4 변환 행렬이다.
    • 쉽게 말해 “이 voxel이 실제 몸속 어디에 해당하는가?”를 알려주는
      좌표 변환표다.
    • 이걸 비유하면, voxel은 레고블록이고
      affine은 “이 블록이 실제 위치가 어디인지”를 알려주는 좌표계 규칙이다.
  • Axial

    • 몸을 위아래 방향으로 잘랐을 때의 단면
    • 가로 단면이라고 생각하면 쉽다.
  • Coronal

    • 몸을 앞뒤 방향으로 나눈 단면
    • 정면에서 바라보는 느낌이다.
  • Sagittal

    • 몸을 좌우로 나눈 단면
    • 옆면에서 바라보는 느낌이다.
  • Slice

    • 이렇게 잘라낸 단면 한 장 자체를 뜻한다.




3. MONAI가 많이 사용되는 이유?

MONAI는 의료영상용으로 자주 반복되는 작업을 체계적으로 묶어 둔 프레임워크다.

예를 들면:

  • NIfTI 로딩
  • metadata 유지(이게 왜 중요한지는 아래에 나온다!)
  • orientation / spacing 보정
  • patch sampling
  • 의료영상용 segmentation 네트워크
  • sliding window inference 같은 3D 추론 방식

즉, 일반 이미지 라이브러리로도 의료영상 AI를 할 수는 있지만,
MONAI를 쓰면 의료영상에서 자주 필요한 규칙과 실수를 줄이면서
더 자연스럽게 파이프라인을 만들 수 있다
.


왜 의료영상에서는 전처리가 더 중요할까? 🧐

같은 비장을 찍었더라도 병원, 장비, 프로토콜이 다르면 데이터가 조금씩 달라진다.

예를 들어,

  • 촬영 방향이 다를 수 있고
  • voxel 간격이 다를 수 있고
  • intensity 값 범위가 다를 수 있다

이 상태 그대로 모델에 넣으면 모델은 장기 구조를 배우는 대신,
장비 차이와 저장 형식 차이에 휘둘릴 수 있다.

그래서 의료영상 전처리의 핵심보통 다음 세 가지다.

  • orientation 맞추기
  • spacing 통일하기
  • intensity 범위 정리하기

의료 데이터는 단순한 “이미지 꾸미기”보다, 공간 의미를 보존하면서 규격을 맞추는 과정이 훨씬 중요하다.
이런 이유 때문에 MONAI는 일반 이미지 라이브러리보다 의료영상에 특화된 처리 도구를 제공한다.

여기서 orientation / spacing / intensity가 각각 무엇인지는
아래 '코드를 통한 흐름 이해'에서 설명하겠다.




4. 코드를 통해 흐름 이해하기

4.1. 코드①: 데이터 경로 준비와 train/val 분할

root_dir = "/content/drive/MyDrive/monai_data"
data_dir = os.path.join(root_dir, "Task09_Spleen")

train_images = sorted(glob.glob(os.path.join(data_dir, "imagesTr", "*.nii.gz")))
train_labels = sorted(glob.glob(os.path.join(data_dir, "labelsTr", "*.nii.gz")))

data_dicts = [
    {"image": img, "label": lbl}
    for img, lbl in zip(train_images, train_labels)
]

train_files = data_dicts[:-9]
val_files = data_dicts[-9:]

여기서는 Task09_Spleen의 image와 label 파일을 각각 모아서,
{"image": 경로, "label": 경로} 형태의 dictionary 리스트를 만든다.

이 구조가 중요한 이유는 MONAI의 dictionary-based transform들이
keys=["image", "label"]처럼 여러 데이터를 동시에 같은 방식으로 변환할 수 있게 해주기 때문이다.

예를 들어 영상만 회전하고 라벨은 회전하지 않으면 정답 위치가 틀어져 버린다.
그래서 의료영상 segmentation에서는 image와 label을 같이, 같은 규칙으로 움직여야 한다.

⚠️ 다만 여기서 두 가지 주의점이 있다.

첫째,
sorted()가 매칭을 도와주긴 하지만, 절대적으로 안전한 방법은 아니다.

왜냐하면 이 방식은 전제조건이 있다.
→ "이미지 파일명과 라벨 파일명이 같은 정렬 규칙을 가져야 한다."

예를 들어:

  • image: case1.nii.gz, case2.nii.gz, case10.nii.gz
  • label: case1.nii.gz, case2.nii.gz, case10.nii.gz

처럼 양쪽이 완전히 같은 naming rule이면 괜찮다.

그런데 이름이 조금이라도 어긋나거나 어느 한쪽에 누락이 존재하면(최악의 가정)
sorted() + zip()만으로는 잘못 매칭될 수 있다.

예를 들어:

  • image: img_001.nii.gz
  • label: label_001.nii.gz

처럼 prefix가 다르거나, 중간에 파일 하나가 빠져 있으면
겉보기엔 정렬되어도 실제 짝이 틀어질 수 있다는 것이다.

그래서 더 안전한 방법은?

실무에서는 보통 공통 ID 기준으로 매칭하는 방법이 더 안전하다.
예를 들면 파일명에서 case ID를 추출해서 딕셔너리로 맞추는 방식이라고 할 수 있다.


둘째,

sorted(...) 후 마지막 9개를 validation으로 쓰는 방식은 실습용으로는 괜찮지만,
실전에서는 random split + seed 고정이 더 안전하다.

파일명이 특정 패턴 순서로 정렬되어 있으면 검증셋이 한쪽으로 치우칠 수 있기 때문이다.

이 두 가지를 반영한 코드를 다시 짜보자면?

import os
import glob
import re
import random
from pathlib import Path

# -----------------------------
# 1. 확장자 제거
# -----------------------------
def strip_nii_ext(path):
    name = Path(path).name
    if name.endswith(".nii.gz"):
        return name[:-7]
    elif name.endswith(".nii"):
        return name[:-4]
    return Path(path).stem


# -----------------------------
# 2. 파일명에서 숫자 ID 추출(전제조건: 파일명에 숫자로 구분이 되어있어야 함)
# -----------------------------
def extract_id(path):
    """
    파일명 안의 숫자들을 찾아서 ID로 사용
    예:
        img_001.nii.gz -> 001
        case_12_ct.nii.gz -> 12
        patient_03_phase_2.nii.gz -> 03_2
    """
    stem = strip_nii_ext(path)
    nums = re.findall(r"\d+", stem) 
    # re.findall(r"\d+", stem)의 역할:
    # stem 문자열 안에 들어 있는 숫자 부분들을 전부 찾아서 리스트로 반환

    if len(nums) == 0:
        raise ValueError(f"파일명에서 숫자 ID를 찾지 못했습니다: {path}")

    return "_".join(nums)


# -----------------------------
# 3. ID -> path 딕셔너리 만들기
# -----------------------------
def build_path_dict(paths, kind="image"):
    path_dict = {}

    for p in paths:
        file_id = extract_id(p)

        if file_id in path_dict:
            raise ValueError(
                f"중복된 {kind} ID 발견: {file_id}\n"
                f"기존 파일: {path_dict[file_id]}\n"
                f"새 파일: {p}"
            )

        path_dict[file_id] = p

    return path_dict


# -----------------------------
# 4. image / label 안전하게 연결
# -----------------------------
def build_monai_data_list(image_paths, label_paths):
    image_dict = build_path_dict(image_paths, kind="image")
    label_dict = build_path_dict(label_paths, kind="label")

    # 각 dict(image, label)의 key값만 모음
    image_ids = set(image_dict.keys())
    label_ids = set(label_dict.keys())

    common_ids = sorted(image_ids & label_ids)
    # 둘 다 존재하는 경우
    only_image_ids = sorted(image_ids - label_ids)
    # image에만 존재하는 경우
    only_label_ids = sorted(label_ids - image_ids)
    # label에만 존재하는 경우

    # 안전장치
    if only_image_ids:
        print("[label이 없는 image ID]")
        for x in only_image_ids:
            print("-", x)

    if only_label_ids:
        print("[image가 없는 label ID]")
        for x in only_label_ids:
            print("-", x)

    if len(common_ids) == 0:
        raise ValueError("공통 ID가 하나도 없습니다. 파일명을 확인하세요.")

    # image, label 딕셔너리 묶음 리스트 만들기
    data_list = []
    for file_id in common_ids:
        data_list.append({
            "image": image_dict[file_id],    # "image": 이미지 경로
            "label": label_dict[file_id],    # "label": 라벨 경로
            "case_id": file_id,              # "case_id": 추출한 ID
        })

    return data_list


# -----------------------------
# 5. 랜덤 셔플 후 split
# -----------------------------
def split_data(data_list, val_size=0.2, seed=42):
    data_list = data_list.copy()
    random.seed(seed)
    random.shuffle(data_list)

    if isinstance(val_size, float):
        n_val = int(len(data_list) * val_size)
    else:
        n_val = val_size

    val_files = data_list[:n_val]
    train_files = data_list[n_val:]

    return train_files, val_files
    
# -----------------------------
# 6. 실 사용 예시
# -----------------------------
    
root_dir = "/content/drive/MyDrive/monai_data"
data_dir = os.path.join(root_dir, "Task09_Spleen")

image_paths = glob.glob(os.path.join(data_dir, "imagesTr", "*.nii.gz"))
label_paths = glob.glob(os.path.join(data_dir, "labelsTr", "*.nii.gz"))

data_dicts = build_monai_data_list(image_paths, label_paths)

train_files, val_files = split_data(data_dicts, val_size=9, seed=42)

print("전체 데이터 수:", len(data_dicts))
print("학습 데이터 수:", len(train_files))
print("검증 데이터 수:", len(val_files))

print("\n[매칭 예시]")
for item in data_dicts[:5]:
    print(item["case_id"], "->", os.path.basename(item["image"]), "|", os.path.basename(item["label"]))



4.2. 코드 ② nibabel로 원본 NIfTI 들여다보기 ⭐️⭐️

img = nib.load(train_images[0])
arr = img.get_fdata()

print("원본 shape:", arr.shape)
print("affine:\n", img.affine)
print("voxel size (pixdim):", img.header["pixdim"])

이 코드는 “NIfTI 파일 안에 무엇이 들어 있는가?”를 눈으로 확인하는 단계다.

여기서 꼭 알아둘 점

  • arr.shape는 원본 배열 shape다.
    보통 (H, W, D) 또는 경우에 따라 4D shape가 나올 수 있다.
  • img.affine은 이 배열 좌표를 실제 공간 좌표와 연결하는 행렬이다.

그런데 img.header["pixdim"]을 그대로 voxel size라고 부르면 조금 부정확하다.

NIfTI에서 pixdim[0]은 방향 관련 값(qfac)이고,
실제 voxel 간격은 보통 pixdim[1:4]로 본다. 더 안전하게는 아래처럼 쓰는 편이 좋다.

print("voxel spacing:", img.header["pixdim"][1:4])


📌 이게 무슨 말인가?

먼저 pixdim은 NIfTI header 안에 있는 값들이다.
img.header["pixdim"]의 결과가 아래와 같다고 해보자.

[-1. 0.8 0.8 2.5 0. 0. 0. 0. ]

사실상 pixdim 전체는 “영상의 간격 관련 정보 묶음”인데,

  • pixdim[0] → 방향 부호 같은 정보
    • pixdim[0] = +1 → 그 축을 그대로 쓴다
    • pixdim[0] = -1 → 그 축을 반대로 뒤집어 쓴다
    • 다만 어느 축이 실제 몸의 left/right냐 같은 건 quaternionaffine까지 같이 봐야 해서, pixdim[0] 하나만 보고는 확정 못 한다.
  • pixdim[1] → x축 간격
  • pixdim[2] → y축 간격
  • pixdim[3] → z축 간격

이런 식이다.

즉, 우리가 진짜 알고 싶은 voxel spacing은 보통
img.header["pixdim"][1:4]인 것이다.


📌 그렇다면 voxel spacing이 도대체 무엇인가?

“voxel 하나가 실제 몸속에서 얼마나 긴 길이를 차지하는가”

예를 들어:

voxel spacing: [0.8, 0.8, 2.5] 라면,

  • 가로 방향 voxel 1칸 = 0.8 mm
  • 세로 방향 voxel 1칸 = 0.8 mm
  • 깊이 방향 voxel 1칸 = 2.5 mm

라는 뜻이다.

즉, 배열에서는 그냥 한 칸이지만 실제 몸속에서는 축마다 칸 크기가 다를 수 있다.

그래서 의료영상에서는 단순히 배열 shape만 보면 안 되고,
spacing까지 같이 봐야 진짜 크기와 비율을 이해할 수 있고,
메타데이터가 필요한 이유이기도 하다.


이제 시각화 코드도 해석해보자 ⭐️

z_mid = arr.shape[2] // 2

plt.figure(figsize=(5, 5))
plt.imshow(arr[:, :, z_mid].T, cmap="gray", origin="lower")
plt.title("Original Axial Slice")
plt.axis("off")
plt.show()

이 코드는 원본 3D 배열에서 세 번째 축(axis=2, 보통 depth 축) 기준으로
가운데 슬라이스를 꺼내 시각화한다.

이 데이터가 일반적인 (H, W, D) 형태로 저장되어 있다면,
arr[:, :, z_mid]는 보통 가운데 axial slice로 해석할 수 있다.

다만 여기서 해석 시 주의할 점은,
아직 Orientationd(axcodes="RAS") 적용 전 이라는 것이다.

즉, arr[:, :, z_mid]가 axial 단면이라고 볼 수는 있어도
화면에서

  • 가로(H)가 환자 Left(왼) -> Right(오) 인지 Right(오) -> Left(왼) 인지 모름
  • 세로(W)가 Posterior(뒤) -> Anterior(앞) 인지 반대인지 모름
  • z 증가가 Inferior(아래) -> Superior(위) 인지 반대인지 모름

같은 해부학적 방향 의미는 orientation을 통일하기 전에는 파일마다 다를 수 있다.

또한, 만약 배열이 (H, W, D)가 아닌 다른 형태였다면 orientation을 거치며 배열이 달라질 수 있다.

따라서 이 그림은 원본 배열의 가운데 axial slice를 확인하는 용도로 이해하면 되고,
여러 샘플에서 축의 해부학적 의미를 일관되게 해석하려면
Orientationd(axcodes="RAS") 같은 정렬 이후에 보는 것이 더 정확하다.

Orientationd(axcodes="RAS") 정렬 이후 이미지 확인

  • arr[:, :, z_mid]: 중앙 axial slice
  • arr[:, y_mid, :]: 중앙 coronal slice
  • arr[x_mid, :, :]: 중앙 sagittal slice



4.3. 코드 ③ base_transforms가 하는 일

base_transforms = Compose([
    LoadImaged(keys=["image", "label"]),
    EnsureChannelFirstd(keys=["image", "label"]),
    Orientationd(keys=["image", "label"], axcodes="RAS"),
    Spacingd(
        keys=["image", "label"],
        pixdim=(1.5, 1.5, 2.0),
        mode=("bilinear", "nearest"),
    ),
    ScaleIntensityRanged(
        keys=["image"],
        a_min=-57,
        a_max=164,
        b_min=0.0,
        b_max=1.0,
        clip=True,
    ),
    CropForegroundd(keys=["image", "label"], source_key="image"),
    EnsureTyped(keys=["image", "label"]),
])

이 부분이 사실상 이번 수업의 핵심이다.
의료영상 전처리 파이프라인이 어떤 철학으로 구성되는지가 다 들어 있다.

1) LoadImaged

파일 경로를 실제 image/label 데이터로 읽어온다.

단순히 ndarray만 읽는 것이 아니라,
의료영상에 필요한 metadata까지 함께 다루는 시작점이라고 보면 된다.


2) EnsureChannelFirstd

채널축을 맨 앞으로 보낸다.

원본 CT는 보통 (H, W, D)처럼 채널축이 없기 때문에,
이 단계를 거쳐 (1, H, W, D) 같은 형태로 맞춘다.


3) Orientationd(axcodes="RAS")

영상 방향을 RAS 기준으로 정렬한다.

의료영상은 같은 장기를 찍었어도 저장 방향이 제각각일 수 있다.
이걸 통일하지 않으면 어떤 데이터는 왼쪽이 먼저, 어떤 데이터는 오른쪽이 먼저 들어오는 식으로 모델이 혼란스러워진다.

이 transform은 이름 그대로 방위를 맞추는 작업이라고 볼 수 있다.


4) Spacingd(pixdim=(1.5, 1.5, 2.0)) ⭐️⭐️⭐️

voxel 간격을 통일한다. (이 부분이 정말 중요하다.)

의료영상에서 배열 shape가 같다고 해서 실제 공간 크기도 같은 것이 아니다.
voxel spacing이 다르면 같은 10칸도 실제 길이는 달라질 수 있다.

그래서 spacing을 통일하는 건,

줄 간격이 제각각인 지도를 동일한 눈금 종이로 다시 옮겨 그리는 작업에 가깝다.

여기서 image와 label의 interpolation mode를 다르게 준 것도 핵심이다.

  • image: bilinear
  • label: nearest

📌 이게 무슨 의미인가?

Spacingd는 새 voxel 격자를 만들면서, 원래 없던 위치의 값을 어떻게 채울지 결정해야 한다.

MONAI는 resampling 시 mode로 'bilinear'와 'nearest' 같은 보간 방식을 받는다.

image에 사용하는 'bilinear'주변 voxel 값을 참고중간값을 부드럽게 추정하는 방식이다.
CT intensity처럼 연속적인 값에는 이런 방식이 자연스럽다.

다만 3D 영상에서는 개념적으로는 주변 8개 voxel을 참고하는
'trilinear'에 해당하는 감각으로 이해하면 된다.

반면 label에 사용하는 nearest
새 위치에서 가장 가까운 원래 voxel의 클래스 값을 그대로 가져오는 방식이다.
이산적인 정답 마스크에서는 이 방식이 중요하다.

예를 들어, 배경=0 / 비장=1인 라벨에서 bilinear처럼 값을 섞어버리면
0.3, 0.7 같은 애매한 값이 생길 수 있는데, 이런 값은 segmentation label로서 의미가 없다.
그래서 label은 class index가 깨지지 않도록 nearest를 사용한다.

즉, 이 단계는

  • image는 부드럽게 보간하고
  • label은 클래스 값이 보존되도록 복사하는 과정이라고 이해하면 된다.


그렇다면 voxel은 3D 입체 구조인데, '가깝다'를 판단하는 기준이 어떻게 되는가? 🧐

핵심은 voxel을 “작은 상자”로 보더라도, nearest를 계산할 때는
보통 그 상자의 “중심점(center)” 기준으로 가깝다를 판단한다.

그래서 새 voxel 위치가 생기면:

  • 새 voxel의 중심 위치
  • 주변 원본 voxel들의 중심 위치

이 둘 사이 거리를 보고 가장 가까운 것을 고른다.


여기서 또 다시 생긴 의문: 그럼 거리는 같은데 클래스가 다르면..? 어떤걸 고르는가? 🧐

여기에 대한 답을 정리하자면:

  • tie가 나도 nearest는 항상 하나만 고른다.
  • “정확히 어느 쪽이 선택되느냐”는 nearest vs nearest-exact 같은
    구현 차이까지 포함해서 봐야 한다.
    (PyTorch는 두 모드가 서로 다른 nearest 알고리즘임을 공식적으로 구분한다.)

즉, 만약 새 voxel이 두 원본 voxel과 정확히 같은 거리에 있다면,
nearest interpolation 구현체의 tie-breaking 규칙에 따라
한쪽 voxel이 선택된다.


5) ScaleIntensityRanged

CT intensity를 특정 구간으로 자르고 다시 0~1 범위로 rescale한다.

이건 단순 정규화가 아니라 windowing + rescaling에 더 가깝다.
CT는 intensity 범위가 매우 넓고, 비장을 볼 때 모든 범위가 다 중요한 것은 아니다.

즉, “배경과 너무 과한 고밀도 영역은 덜 강조하고,
비장 주변 구조가 잘 보이는 범위에 집중하도록 밝기 창문을 맞춘다”는 느낌이다.

📌 a_min=-57, a_max=164의 의미?

이건 임의의 숫자라기보다, 우리의 목적인 비장 segmentation에 유리한
CT intensity window를 잡아주는 값으로 이해하면 된다.

  • 너무 낮은 값: 공기나 배경일 가능성이 큼
  • 너무 높은 값: 뼈나 매우 고밀도 구조일 가능성이 큼
  • 중간의 적절한 범위: 비장과 주변 복부 조직을 구분하는 데 더 유용

이 처리는 카메라 사진으로 비유하면
너무 어두운 부분과 너무 밝은 부분을 잘라내고,
내가 보고 싶은 피사체가 잘 보이도록 노출을 맞추는 것과 비슷하다.

중요한 건 이 숫자가 “절대 정답”은 아니라는 점이다.
데이터셋과 장기, 과제에 따라 window 범위는 달라질 수 있다.
이번 실습에서는 spleen task에 맞춘 실전형 기본값이라고 보면 된다.

결과적으로 intensity가 다음과 같이 잘려서 rescale 된다.

  • -57 → 0
  • 164 → 1
  • 그 사이 값들 → 0~1 사이 어딘가로 선형 변환
  • -57보다 작은 값 → 0으로 클리핑(clip=True이기 때문)
  • 164보다 큰 값 → 1로 클리핑(clip=True이기 때문)
    만약 clip=False였다면 0보다 작거나 1보다 큰 값으로 계속 선형 변환될 수 있다.

6) CropForegroundd

배경을 줄이고, 실제로 의미 있는 foreground 영역 중심으로 잘라낸다.
foreground는 'source_key'를 기준으로 정하고, 필요하면 margin도 추가할 수 있다.

3D 의료영상은 빈 공간이 많다.
이걸 그대로 두면 계산량이 커지고, 모델도 불필요한 배경을 많이 보게 된다.

그래서 이 단계는 사진에서 피사체 주변 빈 여백을 잘라내는 작업이라고 이해하면 쉽다.

여기서 알아두어야할 점은, 앞서 Spacingd를 통해 물리적 척도를 맞추었기 때문에
이 단계를 지나면 케이스마다 shape이 다시 달라질 수 있다
는 것이다.

7) EnsureTyped

마지막으로 모델 입력에 맞는 타입으로 정리한다.

즉, “이제 전처리가 끝났고, 학습 파이프라인으로 넘길 준비가 됐다”는 마무리 단계다.


4.4. 코드 ④ CacheDataset과 DataLoader

train_ds_basic = CacheDataset(
    data=train_files,
    transform=base_transforms,
    cache_rate=1.0,
    num_workers=0,
)

val_ds_basic = CacheDataset(
    data=val_files,
    transform=Compose([
        LoadImaged(keys=["image", "label"]),
        EnsureChannelFirstd(keys=["image", "label"]),
        Orientationd(keys=["image", "label"], axcodes="RAS"),
        Spacingd(
            keys=["image", "label"],
            pixdim=(1.5, 1.5, 2.0),
            mode=("bilinear", "nearest"),
        ),
        ScaleIntensityRanged(
            keys=["image"],
            a_min=-57,
            a_max=164,
            b_min=0.0,
            b_max=1.0,
            clip=True,
        ),
        CropForegroundd(keys=["image", "label"], source_key="image"),

        # Day_4 UNet validation/inference 연결을 위해 추가
        DivisiblePadd(keys=["image", "label"], k=16),

        EnsureTyped(keys=["image", "label"]),
    ]),
    cache_rate=1.0,
    num_workers=0,
)

train_loader_basic = DataLoader(
    train_ds_basic,
    batch_size=1,
    shuffle=True,
    num_workers=0,
)

val_loader = DataLoader(
    val_ds_basic,
    batch_size=1,
    shuffle=False,
    num_workers=0,
)

sample_basic = next(iter(train_loader_basic))
sample_val = next(iter(val_loader))

print("basic train image shape:", sample_basic["image"].shape)
print("basic train label shape:", sample_basic["label"].shape)
print("val image shape:", sample_val["image"].shape)
print("val label shape:", sample_val["label"].shape)

CacheDataset은 전처리 결과를 캐시해두어 반복 학습을 빠르게 만드는 도구다. ⭐️⭐️

MONAI 문서 기준으로,
Compose 안에서 첫 번째 randomized transform 이전까지의 결과를 캐시하는 방식으로 동작한다.
그래서 deterministic transform을 앞에 최대한 몰아두는 것이 권장된다.
(만약 뒤쪽에 deterministic transform이 하나 더 있더라도,
첫 random transform 뒤에 있으면 그건 캐시 구간에 포함되지 않는다.)


[출처: MONAI_CacheDataset]

이 실습에서 base_transforms는 모두 deterministic이므로,
한 번 전처리한 결과를 저장해두고 epoch마다 다시 비싼 연산을 반복하지 않게 해준다.

cache_rate=1.0은 “가능한 전체 데이터를 캐시하겠다”는 요청사항인데,
다만 실제 캐시 수는 min(cache_num, len(data) * cache_rate, len(data))로 정해진다.
지금 코드에서는 cache_num이 기본값(sys.maxsize)이기 때문에,
cache_rate=1.0일 때 실제 캐시 개수는 거의 항상 len(data)에 의해 결정된다.

또 MONAI 문서에 따르면
cache content는 첫 epoch 전에 메인 프로세스에서 준비되므로,
데이터가 크면 초기 준비 시간이 길어지고 시스템 RAM 사용량도 커질 수 있다.

대형 3D 데이터셋에서는 cache_ratecache_num을 조절해
메모리와 속도 사이 균형을 잡는 것이 필요하다.

그럼 cache_rate=0.5일때는 어느 기준으로 50%가 저장되나? 🧐

  • 기본 기준은 현재 dataset 리스트 순서
  • DataLoader(shuffle=True)는 보통 캐시가 만들어진 뒤의 배치 순서만 바꾸므로,
    어떤 샘플이 캐시되는지는 dataset 생성 시점의 data 순서에 더 직접적으로 좌우된다.
  • 캐시는 set_data() 시 계산되고, 이후 DataLoader가 그 캐시를 읽는다.

validation용 파이프라인에 DivisiblePadd(k=16)가 들어간 이유 ⭐️⭐️

DivisiblePadd(keys=["image", "label"], k=16)

이 부분도 그냥 지나가기 쉬운데 꽤 중요하다.

U-Net 계열 모델은 downsampling과 upsampling을 반복한다.

예를 들어 stride=2를 여러 번 쓰면 입력 크기가 계속 절반씩 줄어든다.
이때 입력 크기가 특정 수로 나누어떨어지지 않으면
encoder-decoder 연결 시 shape mismatch가 날 수 있다.

그래서 k=16은
“입력 spatial size를 16의 배수가 되도록 padding해서
모델이 안정적으로 처리하게 만든다”
는 의미로 이해하면 된다.

즉, 기계를 통과할 상자를 규격 박스에 맞춰 넣는 작업과 비슷하다.

학습에서는 patch crop을 이미 고정 크기 (96, 96, 96)로 맞추고 있으니 큰 문제가 덜하지만(아래 코드 참고),
검증/추론에서는 전체 volume 크기가 제각각일 수 있으므로 이런 padding이 특히 중요하다.



4.5. 코드 ⑤ patch 기반 학습은 왜 필요한가?

patch_transforms = Compose([
    LoadImaged(keys=["image", "label"]),
    EnsureChannelFirstd(keys=["image", "label"]),
    Orientationd(keys=["image", "label"], axcodes="RAS"),
    Spacingd(
        keys=["image", "label"],
        pixdim=(1.5, 1.5, 2.0),
        mode=("bilinear", "nearest"),
    ),
    ScaleIntensityRanged(
        keys=["image"],
        a_min=-57,
        a_max=164,
        b_min=0.0,
        b_max=1.0,
        clip=True,
    ),
    CropForegroundd(keys=["image", "label"], source_key="image"),
    RandCropByPosNegLabeld(
        keys=["image", "label"],
        label_key="label",
        spatial_size=(96, 96, 96),
        pos=1,
        neg=1,
        num_samples=4,
        image_key="image",
        image_threshold=0,
    ),
    EnsureTyped(keys=["image", "label"]),
])

patch_ds = CacheDataset(
    data=train_files,
    transform=patch_transforms,
    cache_rate=1.0,
    num_workers=0,
)

patch_loader = DataLoader(
    patch_ds,
    batch_size=1,
    shuffle=True,
    num_workers=0,
)

# Day_4 학습에서 실제 사용할 train_loader
train_loader = patch_loader

sample_patch = next(iter(patch_loader))
print("patch image shape:", sample_patch["image"].shape)
print("patch label shape:", sample_patch["label"].shape)

3D CT 전체 볼륨을 한 번에 학습에 넣으면 메모리가 매우 많이 든다.
그래서 보통 작은 3D patch로 잘라서 학습한다.

여기서는 한 번에 (96,96,96) 크기의 패치를 뽑는다.

pos=1, neg=1은 무슨 뜻일까? 🧐

MONAI 문서에서 pos와 neg는 foreground 중심 patch를 뽑을 확률 비율을 정하는 값이다.

pos=1, neg=1이면,
foreground voxel을 중심으로 고를 확률이 1 / (1+1) = 0.5가 된다.

쉽게 말하면,

  • 어떤 patch는 비장이 포함되도록
  • 어떤 patch는 배경 위주가 되도록

반반 정도의 감각으로 골라보자는 뜻이다.

📌 이게 왜 중요할까?

장기 segmentation에서는 전체 volume 대부분이 배경이고, 실제 장기 영역은 상대적으로 작을 수 있다.

아무렇게나 crop하면 배경만 잔뜩 들어간 patch가 너무 많아져서 학습이 비효율적일 수 있다.

즉, 이 transform은
'정답이 들어 있는 문제와 정답이 없는 문제를 균형 있게 연습시키는 샘플러'라고 이해하면 좋다.

num_samples=4

한 이미지에서 patch를 4개 뽑겠다는 의미다.

그래서 batch_size=1이어도 실제로는 한 volume에서 여러 patch가 나오기 때문에,
shape를 출력해보면 보통 다음처럼 보이게 된다.

  • 원본 training sample: (1, H, W, D)
  • patch training sample: (4, 1, 96, 96, 96)

즉, 이미지 1개를 가져와서 그 안에서 patch 4개를 뽑아
미니배치처럼 사용하는 구조라고 보면 된다.

다만 RandCropByPosNegLabeld를 사용하면 아래와 같이 학습된다.

  • 배우는 것: 장기 경계, 국소 texture, patch 안에서의 상대적 구조, 주변 문맥
  • 기본적으로 약한 것: 몸 전체 좌표계에서의 절대 위치 priors

만약 전역 위치 정보가 매우 중요하다면
'더 큰 patch', 'coarse-to-fine 구조', '좌표 정보 추가' 같은 방법을 고려할 수 있다.

basic train loader와 patch loader는 무엇이 다를까? ⭐️

코드에서는 두 가지 로더가 등장한다.

  • train_loader_basic

    • 전체 volume에 가까운 형태를 전처리해서 확인하는 용도
  • patch_loader

    • 실제 학습에 사용할 patch 기반 로더

즉, 이번 실습은

  • 개념 확인용: 전체 구조를 먼저 보고
  • 실제 학습용: 메모리를 고려해 patch로 잘라 학습

이라는 두 단계로 구성되어 있다.

이 코드에서 shape가 어떻게 바뀌는지 정리 ⭐️

가장 많이 헷갈리는 부분이므로, 흐름으로 다시 정리해보자.

원본 NIfTI를 nibabel로 읽었을 때(arr = img.get_fdata()) 보통의 경우:
(H, W, D)

EnsureChannelFirstd 이후:
(1, H, W, D)

RandCropByPosNegLabeld(..., spatial_size=(96,96,96), num_samples=4) 이후 DataLoader:
(4, 1, 96, 96, 96)

즉:

  • D는 원래부터 있던 depth 축
  • 맨 앞 1은 channel 축
  • 맨 앞 4는 한 volume에서 뽑은 patch 개수

이 구조만 정확히 잡혀도 MONAI 데이터 흐름이 훨씬 덜 헷갈린다.




5. 요약 ⭐️

이번 실습은 결국 아래 문장으로 정리된다.

“NIfTI 의료영상을 불러와서,
방향과 간격과 intensity를 통일하고,
장기가 있는 위치를 잘 학습하도록 patch를 뽑아
3D segmentation 모델에 넣는 과정”

조금 더 풀면:

  • .nii.gz 파일을 읽는다
  • 채널축을 맞춘다
  • 방향을 RAS로 통일한다
  • voxel spacing을 통일한다
  • intensity 범위를 비장에 맞게 정리한다
  • 몸통 주변만 crop한다
  • 학습은 patch 단위로 한다
  • 검증은 전체 volume에 가깝게 본다
  • UNet이 처리하기 좋게 padding도 맞춘다

이 흐름을 이해해야 각 코드를 왜 작성하는지 이해할 수 있다.

다음 포스팅은 실제 학습 결과 해석으로 돌아오겠다.

profile
R&D 분야의 경험을 토대로 커리어 확장에 도전중인 개발꿈나무입니다.

0개의 댓글