ResNet 기반 망막증 분류 #2 이미지 전처리 #
#2026-02-27
#1 망막 이미지 데이터
Kaggle에서 받은 안저 사진들은 전 세계 다양한 병원에서 서로 다른 장비로 찍힌 것이다. 어떤 사진은 2000픽셀짜리이고, 어떤 사진은 5000픽셀이 넘는다. 어떤 사진은 거의 정사각형이고, 어떤 사진은 직사각형이다. 공통적인 건 하나인데, 망막은 원형이라 사진 한가운데에 둥근 밝은 영역으로 찍히고, 그 주변은 까만 여백으로 채워져 있다는 것이다. 신경망에 이미지를 넣으려면 모든 이미지의 크기가 같아야 한다. 행렬 연산이 고정된 차원을 요구하기 때문이다. 그리고 까만 여백은 아무런 의학적 정보가 없는데도 픽셀을 차지하고 있으니 낭비다. 따라서 전처리에서 할일은 각 이미지에서 망막이 있는 부분만 정확히 잘라내서, 모두 동일한 512×512 크기로 맞추는 것이다. 여기서 망막의 정확한 위치가 사진마다 다르다. 어떤 사진에서는 약간 왼쪽으로 치우쳐 있고, 어떤 사진에서는 위쪽으로 치우쳐 있다. 사람이 수만 장을 하나하나 보고 잘라낼 수는 없으니, 자동으로 망막의 중심을 찾아서 잘라내는 알고리즘이 필요하다.
원본 이미지:
┌──────────────────────────────────┐
│ ████████████████████████████ │
│ ████ 망막(둥글게) ████ │ ← 크기: 2000~5000px 이상
│ ████ (밝은 원형 영역) ████ │ 가장자리: 검정 여백
│ ████████████████████████████ │
└──────────────────────────────────┘
필요한 이미지:
┌──────────┐
│ │ ← 512×512, 망막 중앙 정사각형
│ 망막만 │ 균일한 크기
│ │
└──────────┘
#
#2 Canny 엣지 검출: 망막 경계 찾기
망막의 중심을 찾으려면 먼저 망막이 어디에 있는지를 알아야 한다. 여기서 Canny 엣지 검출이라는 고전적인 컴퓨터 비전 기법이 등장한다. Canny의 핵심 아이디어는 이미지에서 밝기가 급격하게 변하는 곳을 찾는다. 안저 사진을 생각해보자. 까만 여백 영역은 픽셀값이 거의 0이다. 망막 영역은 밝아서 픽셀값이 200 정도 된다. 이 둘의 경계에서 픽셀값이 0에서 200으로 갑자기 뛴다. Canny는 바로 이 급격한 변화를 감지해서 “여기가 경계(엣지)다"라고 표시한다.
def cut_raw_images(all_images, path):
import cv2
for i, img_path in enumerate(all_images):
img = cv2.imread(os.path.join(path, img_path))
edges = cv2.Canny(img, 10, 30)
coords = list(zip(*np.where(edges > 0)))
n_p = len(coords)
cv2.Canny(img, 10, 30)에서 10과 30은 임계값이다. 밝기 변화가 30보다 크면 확실한 엣지로 판정하고, 10보다 작으면 엣지가 아니라고 무시한다. 10과 30 사이의 변화는 조건부로 판정하는데, 이미 확인된 강한 엣지에 연결되어 있으면 엣지로 인정하고 그렇지 않으면 버린다. 이 두 단계 판정 방식 덕분에 중요한 경계는 놓치지 않으면서 무의미한 잡음은 걸러낼 수 있다.
# Canny 엣지 검출 직관
원본 이미지 픽셀값:
0 0 0 0 200 200 200 200
0 0 0 0 200 200 200 200
0 0 0 0 200 200 200 200
Canny(10, 30) 결과:
0 0 0 0 255 0 0 0
0 0 0 0 255 0 0 0
↑
급격한 밝기 변화 = 엣지
임계값을 10과 30으로 아주 낮게 잡은 이유는 안저 사진의 특성 때문인데, 까만 배경에서 망막 영역으로 넘어가는 경계는 대비가 뚜렷하니까 낮은 임계값으로도 충분히 잡히고, 오히려 너무 높이 잡으면 대비가 약한 사진에서 경계를 놓칠 수 있다.
#
#3 중심 찾기: 1%와 99% 지점의 평균
coords.sort(key=lambda x: (x[0], x[1]))
center_0 = int(
(coords[int(0.01 * n_p)][0] + coords[int(0.99 * n_p)][0]) / 2)
coords.sort(key=lambda x: (x[1], x[0]))
center_1 = int(
(coords[int(0.01 * n_p)][1] + coords[int(0.99 * n_p)][1]) / 2)
Canny를 돌리면 엣지로 판정된 픽셀들의 좌표 목록이 나온다. 이 좌표들은 대부분 망막의 둥근 경계를 따라 분포해 있을 것이다. 이 좌표들로부터 망막의 중심을 어떻게 구할까? 가장 단순한 방법은 모든 엣지 좌표의 평균을 내는 것이다. 하지만 이 방법에는 함정이 있다. 이미지 구석에 찍힌 먼지 자국이나 촬영 장비의 문자 표시 같은 것도 엣지로 잡힐 수 있는데, 이런 이상치(outlier)가 평균을 크게 왜곡할 수 있다. 그래서 엣지 좌표들을 y축 기준으로 정렬한 다음, 맨 위 1% 지점의 y값과 맨 아래 99% 지점의 y값의 평균을 수직 중심으로 잡는다. x축에 대해서도 똑같이 한다. 왼쪽 1% 지점과 오른쪽 99% 지점의 x값 평균이 수평 중심이 된다. 1%와 99%를 쓰면 양쪽 끝 1%의 극단값을 무시하는 효과가 있다. 통계에서 말하는 “트림드 평균(trimmed mean)“과 비슷한 원리다. 이렇게 하면 잡음에 덜 민감한 견고한 중심 추정이 가능하다.
#
#4 정사각형 자르기
edge_size = min(
[center_0, img.shape[0] - center_0,
center_1, img.shape[1] - center_1])
img_cut = img[(center_0 - edge_size):(center_0 + edge_size),
(center_1 - edge_size):(center_1 + edge_size)]
img_cut = cv2.resize(img_cut, (512, 512))
cv2.imwrite(
os.path.join(path, 'cut_' + os.path.splitext(img_path)[0] + '.png'),
img_cut)
중심을 찾았으면 이제 그 중심을 기준으로 정사각형을 잘라야 한다. 여기서 edge_size라는 값을 계산하는데, 이건 중심에서 이미지 경계까지의 최소 거리다. 중심에서 위쪽 경계까지, 아래쪽 경계까지, 왼쪽 경계까지, 오른쪽 경계까지 네 가지 거리 중 가장 짧은 것을 고른다. 왜 최소 거리를 고르느냐? 정사각형이 이미지 바깥으로 삐져나가면 안 되기 때문이다. 중심이 이미지의 정 가운데에 있으면 네 방향의 거리가 비슷하겠지만, 중심이 약간 왼쪽으로 치우쳐 있으면 왼쪽 경계까지의 거리가 가장 짧다. 이 가장 짧은 거리를 반지름으로 삼아야 정사각형이 이미지 안에 안전하게 들어간다.
중심에서 상하좌우로 edge_size만큼 자르면 변의 길이가 2 × edge_size인 정사각형이 된다. 이 정사각형의 크기는 이미지마다 다르다. 원본이 큰 이미지에서는 큰 정사각형이 나오고, 작은 이미지에서는 작은 정사각형이 나온다. 마지막으로 cv2.resize로 이 정사각형을 512×512로 통일한다. 이제 모든 이미지가 같은 크기이고, 망막이 중앙에 위치하며, 불필요한 검정 여백이 최소화된 깔끔한 상태가 된다. 결과물은 cut_원본파일명.png라는 이름으로 저장된다.
#
#5 클래스 불균형 처리
trainLabels.csv:
image,level
10_left,0
10_right,0
13_left,0
...
→ all_labels = {'10_left': 0, '10_right': 0, '13_left': 0, ...}
CSV 파일에 각 이미지의 등급(0~4)이 적혀 있고, 이걸 파이썬 딕셔너리로 변환한다.
all_labels = dict(zip(*np.transpose(np.array(pd.read_csv(label_path)))))
labels = np.array(
[all_labels[os.path.splitext(n)[0][4:]] for n in image_names]).reshape((-1, 1))
파일명에서 “cut_“과 확장자를 떼어내면 원래 이미지 이름이 나오고, 딕셔너리에서 해당 이름의 등급을 찾을 수 있다.
여기서 문제는 등급별 이미지 수가 극심하게 불균형하다. 현실 세계에서는 당연한 일이다. 대부분의 당뇨 환자는 망막증이 없거나 경미하고, 중증 이상인 환자는 상대적으로 드물다. 그래서 등급 0(정상)이 25,000장인 반면 등급 3(중증)은 800장 정도밖에 안 된다. 30배가 넘는 차이다.
모델의 목표는 손실을 최소화하는 것이다. 만약 모든 이미지에 대해 “등급 0"이라고 찍어버리면 어떻게 될까? 25,000장은 맞추고 나머지만 틀린다. 전체 정확도가 이미 70% 이상이다. 모델이 아무것도 안 배우고 그냥 “다 정상이에요"라고 대답해도 그럭저럭 괜찮은 성적을 받는 것이다. 이러면 등급 3, 4 같은 심각한 케이스를 전혀 잡아내지 못하는 쓸모없는 모델이 된다.
classes, cts = np.unique(list(all_labels.values()), return_counts=True)
weight_ratio = dict(zip(classes, np.max(cts) / cts.astype(float)))
weights = np.array([weight_ratio[l[0]] for l in labels]).reshape((-1, 1))
이 문제를 해결하는 방법이 클래스 가중치다. 아이디어는 직관적이다. 희귀한 클래스의 오분류에 더 큰 벌점을 주는 것이다. 가중치 계산 방식을 따라가보자. 먼저 가장 많은 클래스의 샘플 수를 찾는다. 여기서는 등급 0의 25,000이다. 각 클래스의 가중치는 (최대 샘플 수 / 해당 클래스 샘플 수)로 계산한다.
등급 0은 25,000 / 25,000 = 1.0이다. 가장 흔한 클래스니까 가중치가 1이다. 등급 3은 25,000 / 800 = 31.3이다. 31배나 희귀하니까 가중치도 31배다. 이 가중치가 손실 계산에서 하는 역할을 구체적으로 생각해보자. 모델이 등급 0 이미지를 틀리면 손실에 1.0이 곱해진다. 그냥 원래 손실 그대로다. 모델이 등급 3 이미지를 틀리면 손실에 31.3이 곱해진다. 같은 크기의 실수여도 31배 더 큰 벌점을 받는 것이다.
이렇게 하면 모델은 더 이상 “다 정상이라고 하면 편해"라는 안이한 전략을 쓸 수 없게 된다. 등급 3 하나를 틀리는 페널티가 등급 0을 31개 틀리는 것과 맞먹으니까, 모델은 희귀한 중증 사례도 정확히 잡아내는 방향으로 학습할 수밖에 없다.
직관적으로 이건 수업에서 시험 문제의 배점을 조정하는 것과 같다. 쉬운 문제(흔한 클래스)는 1점짜리로 놓고, 어려운 문제(희귀한 클래스)는 31점짜리로 놓으면 학생은 어려운 문제도 무시할 수 없게 된다. 데이터의 양이 부족한 것을 각 샘플의 중요도를 높여서 보상하는 전략인 것이다.
# 클래스 불균형 처리 직관
실제 데이터 분포 (예시):
등급 0: 25,000장 ← 압도적으로 많음
등급 1: 2,400장
등급 2: 5,200장
등급 3: 800장
등급 4: 1,600장
가중치 계산:
max_count = 25,000
weight[0] = 25000/25000 = 1.0 ← 흔한 클래스 가중치 낮음
weight[1] = 25000/2400 = 10.4
weight[2] = 25000/5200 = 4.8
weight[3] = 25000/800 = 31.3 ← 희귀한 클래스 가중치 높음
weight[4] = 25000/1600 = 15.6
# 이 가중치는 손실 계산 시 희귀 클래스의 오분류를 더 크게 패널티를 준다.
#
#6 정리
수만 장의 원본 안저 사진이 들어온다. 크기도 제각각이고, 망막 위치도 사진마다 다르고, 주변은 까만 여백으로 채워져 있다. Canny 엣지 검출이 밝기가 급변하는 경계선을 찾아내고, 그 경계선 좌표들의 분포로부터 망막의 중심을 추정한다. 중심에서 가능한 최대 크기의 정사각형을 잘라내서 512×512로 리사이즈한다. 그 다음 CSV에서 등급 레이블을 읽어 연결하고, 클래스별 빈도의 역수로 가중치를 매겨서 데이터 불균형을 보정한다.
이 모든 과정이 끝나면, 깔끔하게 정규화된 이미지와 균형 잡힌 가중치가 준비된다. 이제 이 데이터를 모델에 넣을 수 있는 상태가 된 것이다.