수민 '-'

플오그래밍

제가 작성하는 모든 글은 절대 상업적인 이용이 아니며, 그저 개인적인 공부 용도로만 사용하는 것임을 밝힙니다.

의료영상 신호처리와 MONAI - Spatial·Frequency, X-ray·CT·MRI, 전처리 실습

MRI, CT, X-ray 같은 의료영상은 모델에 넣기 전에 신호 처리·정규화·증강 단계를 거치는 경우가 많다. 픽셀 값의 의미(밀도, HU, k-space)를 이해하지 못한 채 전처리만 복붙하면, 왜 Spacing을 맞추는지·왜 label은 nearest인지 설명하기 어렵다.

이번 글에서는 (1) Signal Processing 개념, (2) X-ray·CT·MRI 특성, (3) MONAI로 3D 의료영상 전처리·증강 실습까지 정리한다.


1. Signal Processing

Signal Processing(신호 처리)은 MRI, CT, 초음파, ECG 등 다양한 의료 데이터를 분석에 적합한 형태로 정제하고 변환하는 핵심 과정이다. 노이즈 제거, 해상도 보정, 정규화, 필터링, 주파수 변환, 아티팩트 제거 등을 통해 데이터 품질을 높이고, 질병과 관련된 신호를 더 명확하게 만든다. 이후 AI 모델이나 영상 분석 알고리즘이 더 정확하게 진단·예측할 수 있도록 돕는 필수 전처리 단계다.

1-1. Spatial domain

Spatial domain(공간 영역)은 이미지나 신호를 각 위치(픽셀 또는 시간 지점)의 값 그대로 표현하고 처리하는 방식이다. 밝기 조절, 필터링, 블러, 샤프닝, 엣지 검출처럼 각 위치의 값에 직접 연산을 적용한다. 의료영상에서는 노이즈 제거, 영상 보정, 전처리 단계에서 원본 구조를 유지한 채 품질을 올리는 데 핵심적으로 쓰인다.

1-2. Frequency domain

Frequency domain(주파수 영역)은 신호·이미지를 시간·공간 값이 아니라 주파수 성분(저주파·고주파) 으로 분해해 분석하는 방식이다. 전체적인 형태는 저주파, 경계·디테일·노이즈는 고주파로 이해할 수 있다. 푸리에 변환 후 특정 주파수를 강조하거나 제거하는 필터링이 가능하며, MRI의 k-space처럼 데이터가 주파수 영역에서 수집되기도 한다.

  • 저주파(Low Frequency): 큰 구조, 전체 밝기 (예: 장기 형태)
  • 고주파(High Frequency): 디테일, 조직 차이, 텍스처, 세밀한 경계선

1-3. Quantization

Sampling으로 연속 영상을 일정 간격으로 나눈 뒤, 각 위치의 값을 이산 수치로 바꾸는 과정을 Quantization(양자화) 라고 한다. 연속 밝기를 정해진 단계로 나누어 표현하며, 픽셀 값은 0

255(8bit) 또는 0

65535(16bit) 같은 범위를 갖는다. 아날로그 정보를 컴퓨터가 처리할 수 있는 디지털 값으로 바꾸는 핵심 단계다.


2. X-ray

X-ray(엑스레이)는 인체를 통과하는 방사선으로 내부 구조를 2차원 영상으로 표현하는 의료영상 기법이다. 조직 밀도 차이에 따라 투과 정도가 달라져 뼈는 밝게, 공기는 어둡게 보인다. 디지털 X-ray는 그레이스케일 픽셀 데이터로 구성되며, 노이즈 제거·정규화 등 전처리 후 AI 입력으로 쓰인다. 폐질환, 골절 탐지 등에서 널리 쓰이고, 빠르고 비용이 낮아 의료 AI에서 가장 흔한 데이터 중 하나다.

밀도가 높을수록 밝게 보임


3. CT

Computed Tomography(CT)는 X-ray를 여러 각도에서 촬영한 뒤 재구성해 단면(슬라이스) 형태의 3D 영상으로 표현한다. Hounsfield Unit(HU)로 조직 밀도를 정량화해 뼈·장기·혈관을 구분할 수 있으며, X-ray보다 해부학적 정보가 풍부하다. AI에서는 정규화, 윈도잉, 리사이즈 등 전처리 후 종양 탐지, 장기 분할, 병변 분석에 활용된다.

CT 영상은 장비 제조사·병원·촬영 프로토콜에 따라 방사선 Dose(흡수량)가 달라져, 동일 부위라도 노이즈·대비·해상도 차이가 난다. 서로 다른 환경에서 수집된 CT를 표준화하거나, 다양한 조건을 반영한 데이터 증강으로 분포를 맞춰 일반화 성능을 높이는 과정이 필요하다.


4. MRI

MRI(Magnetic Resonance Imaging)는 강한 자기장과 라디오파로 수소 원자 신호를 측정해 영상으로 재구성한다. 방사선 없이 연부조직(뇌, 근육, 장기 등)을 선명하게 볼 수 있다. T1, T2, FLAIR 등 촬영 방식에 따라 조직 특성을 다르게 강조하며, 원본은 k-space(주파수 영역) 에서 수집된 뒤 푸리에 변환으로 이미지가 된다. 보통 여러 2D 슬라이스가 쌓인 3D 형태로 제공된다.

  • 우리 몸은 대부분 물로 이루어져 있음
  • 물 = 수소(H)가 많음
  • MRI는 이 수소를 이용해서 이미지를 만듦
  • 몸 안의 수소를 흔들어서 찍는 영상

5. MONAI

MONAI(Medical Open Network for AI)는 의료영상 분석용 딥러닝 프레임워크로, NVIDIA·King's College London 등이 협력해 개발한 오픈소스다. PyTorch 기반이며, CT·MRI·X-ray 같은 3D 데이터의 로딩, 전처리(Spacing, Orientation 등), 증강, 모델·학습 파이프라인까지 의료영상 특화 기능을 제공한다. DICOM/NIfTI 지원, 3D 텐서 처리, 소규모 데이터셋용 augmentation 등이 포함되어 연구·개발 효율을 높인다.

5-1. 설치 및 데이터 준비

!pip install monai
import numpy as np
import matplotlib.pyplot as plt
from glob import glob
from pathlib import Path
import os

import monai
from monai.apps import DecathlonDataset, download_and_extract
from monai.data import DataLoader, Dataset
from monai.transforms import (
    EnsureChannelFirstd,
    LoadImaged,
    Spacingd,
    Orientationd,
    ScaleIntensityRanged,
    Compose,
    OneOf,
    CropForegroundd,
    Rand3DElasticd,
    RandAffined,
    RandRotated,
    RandFlipd,
)
import tempfile
from monai.visualize.utils import blend_images, matshow3d
import matplotlib.pyplot as plt
directory = os.environ.get('MONAI_DATA_DIRECTOCY')
root_dir = tempfile.mkdtemp() if directory is None else directory
print(f'root dir: {root_dir}')

resource = 'https://msd-for-monai.s3-us-west-2.amazonaws.com/Task02_Heart.tar'
compressed_file = os.path.join(root_dir, 'Task02_Heart.tar')
data_dir = os.path.join(root_dir, "Task02_Heart")
if not os.path.exists(data_dir):
    download_and_extract(resource, compressed_file, root_dir)

train_images = list((Path(data_dir) / "imagesTr").glob("*.nii.gz"))
train_labels = list((Path(data_dir) / "labelsTr").glob("*.nii.gz"))
data_dicts = [
    {'image': image_name, 'label': label_name}
    for image_name, label_name in zip(train_images, train_labels)
]

train_data_dicts, val_data_dicts = data_dicts[:-9], data_dicts[-9:]
train_data_dicts[0]

MSD Task02(Heart) NIfTI를 받아 image·label 경로 딕셔너리로 묶는다.

5-2. LoadImaged + EnsureChannelFirstd

transform = Compose(
    [
        LoadImaged(keys=['image', 'label']),
        EnsureChannelFirstd(keys=['image', 'label']),
    ]
)
dataset = Dataset(data=val_data_dicts, transform=transform)
print(f"image shape: {dataset[0]['image'].shape}")
print(f"label shape: {dataset[0]['label'].shape}")
print(f"pixel spacing: {dataset[0]['image'].pixdim}")

plt = matshow3d(
    volume=dataset[0]['image'][..., 1::20],
    fig=None,
    title='input image',
    frame_dim=-1,
    show=True,
    cmap='gray',
)
  • LoadImaged: NIfTI·DICOM 경로를 텐서로 로드
  • EnsureChannelFirstd: (H, W, D) → (C, H, W, D)
  • pixdim: 픽셀 간 실제 간격(mm) 정보

5-3. Spacingd — voxel 간격 통일

transform = Compose(
    [
        LoadImaged(keys=['image', 'label']),
        EnsureChannelFirstd(keys=['image', 'label']),
        Spacingd(
            keys=['image', 'label'],
            pixdim=(2, 2, 3),
            mode=('bilinear', 'nearest'),
        ),
    ]
)

dataset = Dataset(data=val_data_dicts, transform=transform)
print(f"image shape: {dataset[0]['image'].shape}")
print(f"label shape: {dataset[0]['label'].shape}")
print(f"pixel spacing: {dataset[0]['image'].pixdim}")

plt = matshow3d(
    volume=dataset[0]['image'][..., 1::20],
    fig=None,
    title='input image',
    frame_dim=-1,
    show=True,
    cmap='gray',
)

image는 bilinear, label은 nearest가 일반적이다. 라벨 클래스 ID가 보간으로 섞이면 안 되기 때문이다.

5-4. CropForegroundd — 배경 제거

transform = Compose(
    [
        LoadImaged(keys=['image', 'label']),
        EnsureChannelFirstd(keys=['image', 'label']),
        CropForegroundd(keys=['image', 'label'], source_key='image'),
    ]
)

dataset = Dataset(data=val_data_dicts, transform=transform)
print(f"image shape: {dataset[0]['image'].shape}")
print(f"label shape: {dataset[0]['label'].shape}")
print(f"pixel spacing: {dataset[0]['image'].pixdim}")

plt = matshow3d(
    volume=dataset[0]['image'][..., 1::20],
    fig=None,
    title='input image',
    frame_dim=-1,
    show=True,
    cmap='gray',
)

dataset[0]['image'].max()

5-5. NormalizeIntensityd

from monai.transforms import NormalizeIntensityd

transform = Compose(
    [
        LoadImaged(keys=['image', 'label']),
        EnsureChannelFirstd(keys=['image', 'label']),
        NormalizeIntensityd(keys='image', channel_wise=True),
    ]
)

dataset = Dataset(data=val_data_dicts, transform=transform)
print(f"image shape: {dataset[0]['image'].shape}")
print(f"label shape: {dataset[0]['label'].shape}")
print(f"pixel spacing: {dataset[0]['image'].pixdim}")

plt = matshow3d(
    volume=dataset[0]['image'][..., 1::20],
    fig=None,
    title='input image',
    frame_dim=-1,
    show=True,
    cmap='gray',
)

채널별 평균 0, 표준편차 1 정규화.

5-6. 기하 증강 — Flip, Rotate90, Affine

from monai.transforms import RandRotate90d

transform = Compose(
    [
        LoadImaged(keys=['image', 'label']),
        EnsureChannelFirstd(keys=['image', 'label']),
        CropForegroundd(keys=['image', 'label'], source_key='image'),
        RandFlipd(keys=['image', 'label'], spatial_axis=[2], prob=1),
        RandRotate90d(keys=['image', 'label'], prob=1, max_k=3),
    ]
)

dataset = Dataset(data=val_data_dicts, transform=transform)
plt = matshow3d(
    volume=dataset[0]['image'][..., 1::20],
    fig=None,
    title='input image',
    frame_dim=-1,
    show=True,
    cmap='gray',
)
transform = Compose(
    [
        LoadImaged(keys=['image', 'label']),
        EnsureChannelFirstd(keys=['image', 'label']),
        CropForegroundd(keys=['image', 'label'], source_key='image'),
        RandAffined(
            keys=['image', 'label'],
            shear_range=(0.5, 0.5),
            mode=('bilinear', 'nearest'),
            padding_mode='zeros',
            prob=1,
        ),
    ]
)

dataset = Dataset(data=val_data_dicts, transform=transform)
plt = matshow3d(
    volume=dataset[0]['image'][..., 1::20],
    fig=None,
    title='input image',
    frame_dim=-1,
    show=True,
    cmap='gray',
)

5-7. image·label 오버레이 (blend_images)

import torch
import matplotlib.pyplot as plt

ret = blend_images(
    image=dataset[0]["image"],
    label=dataset[0]["label"],
    alpha=0.5,
    cmap="hsv",
    rescale_arrays=True,
)
fig, axs = plt.subplots(1, 3)
slice_index = 10 * 5
axs[0].set_title(f"image slice {slice_index}")
axs[0].imshow(dataset[0]["image"][0, :, :, slice_index], cmap="gray")
axs[1].set_title(f"label slice {slice_index}")
axs[1].imshow(dataset[0]["label"][0, :, :, slice_index])
axs[2].set_title(f"blend slice {slice_index}")
axs[2].imshow(torch.moveaxis(ret[:, :, :, slice_index], 0, -1))

5-8. 강한 증강 — k-space 노이즈, 히스토그램 정규화

from monai.transforms import (
    RandKSpaceSpikeNoised,
    AdjustContrastd,
    GaussianSmoothd,
    RandCoarseDropoutd,
    HistogramNormalized,
)

transform = Compose(
    [
        LoadImaged(keys=["image", "label"]),
        EnsureChannelFirstd(keys=["image", "label"]),
        ScaleIntensityRanged(
            keys=["image"],
            a_min=0,
            a_max=1800,
            b_min=0.0,
            b_max=1.0,
            clip=True,
        ),
        CropForegroundd(keys=["image", "label"], source_key="image"),
        RandKSpaceSpikeNoised(
            keys=["image"],
            prob=1,
            intensity_range=(13, 15),
            channel_wise=True,
        ),
    ]
)

dataset = Dataset(data=train_data_dicts, transform=transform)

plt = matshow3d(
    volume=dataset[0]["image"][..., 1::20],
    fig=None,
    title="input image",
    frame_dim=-1,
    show=True,
    cmap="gray",
)
transform = Compose(
    [
        LoadImaged(keys=["image", "label"]),
        EnsureChannelFirstd(keys=["image", "label"]),
        ScaleIntensityRanged(
            keys=["image"],
            a_min=0,
            a_max=1800,
            b_min=0.0,
            b_max=1.0,
            clip=True,
        ),
        CropForegroundd(keys=["image", "label"], source_key="image"),
        HistogramNormalized(keys=["image"], num_bins=10),
    ]
)

dataset = Dataset(data=val_data_dicts, transform=transform)

plt = matshow3d(
    volume=dataset[0]["image"][..., 1::20],
    fig=None,
    title="input image",
    frame_dim=-1,
    show=True,
    cmap="gray",
)
Transform 역할
ScaleIntensityRanged HU 등 값 범위를 (0, 1) 등으로 스케일
RandKSpaceSpikeNoised 주파수(k-space) 영역에 스파이크 노이즈
HistogramNormalized 히스토그램 기반 밝기 정규화

마치며

  • Signal Processing은 공간 영역·주파수 영역 관점에서 의료영상 전처리 이해를 돕고, MRI k-space와도 연결된다.
  • X-ray·CT·MRI는 픽셀 의미와 2D/3D 구조가 달라, 같은 전처리 파이프라인을 그대로 쓰기 어렵다.
  • MONAI는 LoadImagedSpacingdCropForegroundd → 정규화·증강을 Compose로 묶어 3D 세그멘테이션 학습 준비를 빠르게 한다.
  • label에는 보간 시 클래스가 깨지지 않도록 nearest 모드를 쓰는 습관이 중요하다.
  • 다음에는 같은 MSD Heart 데이터로 UNet 학습 루프와 검증 지표(Dice 등)까지 이어서 정리보면 실무 흐름이 완성된다.

참고