수민 '-'

플오그래밍

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

전립선암 WSI EDA - Gleason·ISUP, OpenSlide 패치·마스크 시각화

Kaggle Prostate Cancer Grade Assessment는 전립선 병리 WSI와 Gleason score, ISUP grade 라벨을 함께 제공하는 대회 데이터다. 슬라이드 한 장이 매우 크기 때문에 통째로 읽기보다 OpenSlide read_region으로 좌표·레벨·크기를 지정해 패치를 잘라 보는 방식이 기본이다.

이번 글에서는 (1) 전립선암·Gleason·ISUP 개념, (2) cfg와 경로 설정, (3) EDA, (4) WSI·마스크 3×3 시각화·오버레이, (5) 슬라이드 메타 scatter까지 강의 노트 코드 흐름 그대로 정리한다.


1. OpenSlide 설치 및 개념 정리

!pip install openslide-python

import openslide
import cv2
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path

Prostate Cancer

  • 전립선에 발생하는 암
  • 남성에게 가장 흔한 암 중에 하나로 천천히 자라나지만, 급격하게 빠르게 퍼질 수 있음
  • 암에 대해서 score를 측정하는 것이 중요함 (gleason score라고 함)

Gleason Score

  • 생검을 통해서 암의 Grade의 정도를 판독하는 지표
  • Grade가 높을수록 전이가 큰 것을 의미함
  • Gleason score는 두 점수를 합하여 2~10까지의 점수로 판독함

ISUP

International Society of Urological Pathology(ISUP) 가이드라인을 통해서 최종 Grade를 정의한다.

  • Gleason score 6 = ISUP grade 1
  • Gleason score 7 (3 + 4) = ISUP grade 2
  • Gleason score 7 (4 + 3) = ISUP grade 3
  • Gleason score 8 = ISUP grade 4
  • Gleason score 9-10 = ISUP grade 5

2. 설정(cfg) 및 train DataFrame

class cfg:
    BASE_PATH = '/kaggle/input/competitions/prostate-cancer-grade-assessment'
    data_dir = f'{BASE_PATH}/train_images'
    mask_dir = f'{BASE_PATH}/train_label_masks'
    train_zip_path = 'train.zip'
    mask_zip_path = 'masks.zip'
    sz = 256
    N = 16

# isup_grade: 0(정상), 1~5(암 진행 정도)
# gleason_score: 병리학적 암 구조 점수
# 3+4: 중간 위험
# 4+3: 더 위험
train = pd.read_csv(f'{cfg.BASE_PATH}/train.csv')
train.head()
# image_id(abcd1234) -> /train_images/abcd1234.tiff
train['img_path'] = train['image_id'].apply(lambda x : Path(cfg.data_dir)/ (x+'.tiff'))
train['mask_path'] = train['image_id'].apply(lambda x : Path(cfg.mask_dir)/ (x+'_mask.tiff'))
train.head()

train.isna().sum()

image_id 하나에 WSI 경로·마스크 경로를 붙여 두면, 이후 시각화 함수에서 row.img_path, row.mask_path로 바로 열 수 있다.


3. EDA (Exploratory Data Analysis)

Exploratory Data Analysis는 기본적인 data의 distribution 정보를 파악하고, 데이터에 따른 학습 방법을 설정한 뒤 dataframe 분석을 진행하는 단계다.

import seaborn as sns

train.head()

palette = sns.color_palette('Set2')
sns.set_palette("Set2")

sns.countplot(x='data_provider', data=train)

전립선암 라벨 분포와 병원별 분포 차이:

fig, axs= plt.subplots(1,2,figsize=(15,6))
sns.countplot(y="gleason_score", data=train, ax=axs[0])
axs[0].set_title('gleason_score')
sns.countplot(y="gleason_score", hue="data_provider", data=train, ax=axs[1])
axs[1].set_title('gleason_score by data provider')

fig, axs= plt.subplots(1,2,figsize=(10,6))
sns.countplot(x="isup_grade", data=train, ax=axs[0])
axs[0].set_title('isup grade')
sns.countplot(x="isup_grade", hue="data_provider", data=train, ax=axs[1])
axs[1].set_title('isup grade by data provider')
data = [["Gleason Score", "ISUP Grade"],
        ["0+0", "0"], ["negative", "0"],
        ["3+3", "1"], ["3+4", "2"], ["4+3", "3"],
        ["4+4", "4"], ["3+5", "4"], ["5+3", "4"],
        ["4+5", "5"], ["5+4", "5"], ["5+5", "5"],
        ]

tmp = train.groupby('data_provider')['gleason_score'].value_counts()
df = pd.DataFrame(data={'Exams': tmp.values}, index=tmp.index).reset_index()

fig, axs= plt.subplots(1,2,figsize=(15,6))
sns.barplot(x = 'data_provider', y='Exams',hue='gleason_score',data=df, ax=axs[0])
sns.countplot(y="gleason_score", data=train, ax=axs[1])

정리하면 다음과 같다.

  • karolinska의 경우는 0+0, low score의 데이터가 많음
  • radboud의 경우는 high score의 데이터가 일반적으로 많음
  • 최종적으로 전체 데이터에서는 3+3이 많았음. 3+5는 적은데, 이를 통해서 fold의 나눔을 다양하게 해볼 예정

4. WSI 패치 3×3 시각화 (multi_plot)

3×3 grid로 각 이미지에 ISUP grade, Gleason score, 병원 정보를 함께 표시한다.

def multi_plot(df, region=(0,0), level=-1, crop_size=(256,256)):
    f, ax = plt.subplots(3,3, figsize=(15,15))
    for i, row in enumerate(df.itertuples()):
        image = openslide.OpenSlide(row.img_path)
        slevel = image.level_count -1 if level == -1 else level
        patch = image.read_region(region, slevel, crop_size)
        ax[i//3, i%3].imshow(patch)
        image.close()
        ax[i//3, i%3].axis('off')

        image_id = row.image_id
        data_provider = row.data_provider
        isup_grade = row.isup_grade
        gleason_score = row.gleason_score
        ax[i//3, i%3].set_title(f"ID: {image_id}\nSource: {data_provider} ISUP: {isup_grade} Gleason: {gleason_score}")

    plt.show()

sample_df = train.sample(9)
multi_plot(sample_df)

특정 image_id 9장을 고정해, 동일 좌표·해상도에서 비교:

id_list = [
    '037504061b9fba71ef6e24c48c6df44d',
    '035b1edd3d1aeeffc77ce5d248a01a53',
    '059cbf902c5e42972587c8d17d49efed',
    '06a0cbd8fd6320ef1aa6f19342af2e68',
    '06eda4a6faca84e84a781fee2d5f47e1',
    '0a4b7a7499ed55c71033cefb0765e93d',
    '0838c82917cd9af681df249264d2769c',
    '046b35ae95374bfb48cdca8d7c83233f',
    '074c3e01525681a275a42282cd21cbde',
]

slect_df = train[train['image_id'].isin(id_list)]

# WSI에서 (1780, 1950) 위치를 기준으로 (256, 256) 타일을 추출
# level=0 이므로 가장 높은 해상도(원본)에서 이미지를 확인
multi_plot(slect_df, (1780, 1950), 0, (256, 256))
인자 의미
region level 0 좌표계에서 패치 좌상단 (x, y)
level -1이면 가장 낮은 해상도 레벨, 0이면 원본 해상도
crop_size 읽을 패치 가로·세로 픽셀 수

5. 라벨 마스크 시각화 (mask_multi_plot)

label mask를 시각화해 어느 위치가 정상 / 암 / Gleason 패턴인지 확인한다.

  • black: 배경
  • gray: 정상/비종양 조직
  • green: 낮은 등급 암 영역 또는 Gleason 3
  • yellow: Gleason 4
  • orange: Gleason 5
  • red: 고위험 암 영역
import matplotlib

def mask_multi_plot(df, region=(0,0), level=-1, crop_size=(256,256)):
    f, ax = plt.subplots(3,3, figsize=(15,15))
    for i, row in enumerate(df.itertuples()):
        image = openslide.OpenSlide(row.mask_path)
        slevel = image.level_count -1 if level == -1 else level
        patch = image.read_region(region, slevel, crop_size)
        cmap = matplotlib.colors.ListedColormap(['black', 'gray', 'green', 'yellow', 'orange', 'red'])
        ax[i//3, i%3].imshow(np.asarray(patch)[:,:,0], cmap=cmap, interpolation='nearest', vmin=0, vmax=5)
        image.close()
        ax[i//3, i%3].axis('off')

        image_id = row.image_id
        data_provider = row.data_provider
        isup_grade = row.isup_grade
        gleason_score = row.gleason_score
        ax[i//3, i%3].set_title(f"ID: {image_id}\nSource: {data_provider} ISUP: {isup_grade} Gleason: {gleason_score}")

    plt.show()

mask_multi_plot(sample_df)
mask_multi_plot(slect_df, (1780, 1950), 0, (256, 256))

6. WSI + 마스크 오버레이 (override_image)

병원(center)마다 마스크 클래스 정의가 다르다.

  • radboud: 0 배경, 1 stroma, 2 benign epithelium, 3~5 Gleason grade
  • karolinska: 0 배경, 1 benign, 2 cancer
import PIL

def override_image(df, region=(0,0), level=-1, crop_size=(256,256), center='radboud', alpha=0.8, max_size=(800, 800)):
    f, ax = plt.subplots(3,3, figsize=(15,15))
    for i, row in enumerate(df.itertuples()):

        slide = openslide.OpenSlide(row.img_path)
        mask = openslide.OpenSlide(row.mask_path)
        slevel = slide.level_count -1 if level == -1 else level
        slide_data = slide.read_region(region, slevel, crop_size)
        mask_data = mask.read_region(region, slevel, crop_size)
        mask_data = mask_data.split()[0]

        alpha_int = int(round(255*alpha))
        if center == 'radboud':
            alpha_content = np.less(mask_data.split()[0], 2).astype('uint8') * alpha_int + (255 - alpha_int)
        elif center == 'karolinska':
            alpha_content = np.less(mask_data.split()[0], 1).astype('uint8') * alpha_int + (255 - alpha_int)

        alpha_content = PIL.Image.fromarray(alpha_content)
        preview_palette = np.zeros(shape=768, dtype=int)

        if center == 'radboud':
            # Mapping: {0: background, 1: stroma, 2: benign epithelium, 3: Gleason 3, 4: Gleason 4, 5: Gleason 5}
            preview_palette[0:18] = (np.array([0, 0, 0, 0.5, 0.5, 0.5, 0, 1, 0, 1, 1, 0.7, 1, 0.5, 0, 1, 0, 0]) * 255).astype(int)
        elif center == 'karolinska':
            # Mapping: {0: background, 1: benign, 2: cancer}
            preview_palette[0:9] = (np.array([0, 0, 0, 0, 1, 0, 1, 0, 0]) * 255).astype(int)

        mask_data.putpalette(data=preview_palette.tolist())
        mask_rgb = mask_data.convert(mode='RGB')
        overlayed_image = PIL.Image.composite(image1=slide_data, image2=mask_rgb, mask=alpha_content)
        overlayed_image.thumbnail(size=max_size, resample=0)

        ax[i//3, i%3].imshow(overlayed_image)
        slide.close()
        mask.close()
        ax[i//3, i%3].axis('off')

        image_id = row.image_id
        data_provider = row.data_provider
        isup_grade = row.isup_grade
        gleason_score = row.gleason_score
        ax[i//3, i%3].set_title(f"ID: {image_id}\nSource: {data_provider} ISUP: {isup_grade} Gleason: {gleason_score}")

override_image(slect_df, (1780, 1950), 0, (256, 256))

override_image 호출 시 data_provider에 맞게 center='radboud' 또는 'karolinska'를 맞춰 주는 것이 중요하다.


7. 슬라이드 메타 정보 및 scatter plot

def print_slide_details(slide, show_thumbnail=True, max_size=(600,400)):
    if show_thumbnail:
        display(slide.get_thumbnail(size=max_size))

    spacing = 1 / (float(slide.properties['tiff.XResolution']) / 10000)

    print(f"File id: {slide}")
    print(f"Dimensions: {slide.dimensions}")
    print(f"Microns per pixel / pixel spacing: {spacing:.3f}")
    print(f"Number of levels in the image: {slide.level_count}")
    print(f"Downsample factor per level: {slide.level_downsamples}")
    print(f"Dimensions of levels: {slide.level_dimensions}")

for row in sample_df.itertuples():
    biopsy = openslide.OpenSlide(row.img_path)
    print_slide_details(biopsy)
    biopsy.close()
    break

전체 train 슬라이드에 대해 width, height, spacing, level_count를 수집한다.

from tqdm import tqdm

meta_dict = {'width':[], 'height':[], 'spacing':[], 'level_count':[]}
for row in tqdm(train.itertuples(), total=len(train)):
    slide = openslide.OpenSlide(row.img_path)

    spacing = 1 / (float(slide.properties['tiff.XResolution']) / 10000)

    meta_dict['width'].append(slide.dimensions[0])
    meta_dict['height'].append(slide.dimensions[1])
    meta_dict['spacing'].append(spacing)
    meta_dict['level_count'].append(slide.level_count)

train_df = pd.concat([train, pd.DataFrame(meta_dict)], axis=1)

fig = plt.figure(figsize=(12,6))
ax = sns.scatterplot(x='width', y='height', data=train_df, alpha=0.3)
plt.title("height(y) width(x) scatter plot")
plt.show()

fig = plt.figure(figsize=(12, 6))
ax = sns.scatterplot(x='width', y='height', hue='isup_grade', data=train_df, alpha=0.6)
plt.title("height(y) width(x) scatter plot with target")
plt.show()

슬라이드마다 해상도·물리 스케일(spacing)·피라미드 레벨 수가 다르면, 동일 256×256 패치라도 실제 조직 스케일이 달라질 수 있다. EDA 단계에서 이 분포를 먼저 보는 것이 이후 타일링·학습 설계에 도움이 된다.


마치며

  • 전립선암 WSI는 Gleason score와 ISUP grade로 위험도를 표현하며, 병원(data_provider)마다 라벨·마스크 정의가 다르다.
  • seaborn countplot으로 gleason_score·isup_grade 분포와 karolinska/radboud 편향을 확인한 뒤 fold 설계를 고민하는 것이 좋다.
  • multi_plot, mask_multi_plot, override_image로 동일 좌표 패치에서 조직·라벨 대응을 눈으로 검증할 수 있다.
  • print_slide_details와 width-height scatter로 슬라이드 메타 편차를 파악하면, 이후 타일 샘플링·정규화 전략을 세우기 쉽다.
  • 다음에는 조직 비중이 큰 타일만 고르는 전처리와 학습용 Dataset, fold 분할까지 이어서 정리보면 좋다.

참고