ResNet 기반 망막증 분류 #3 데이터 증강 #
#2026-02-27
#1
딥러닝 모델은 데이터를 많이 볼수록 잘 배운다. 그런데 의료 이미지는 구하기 어렵다. 수만 장이 있다고 해도 수백만 개의 파라미터를 가진 신경망을 학습시키기에는 부족할 수 있다. 데이터가 부족하면 모델은 훈련 이미지의 세부 사항까지 통째로 외워버리고, 처음 보는 이미지에서는 엉뚱한 판단을 내린다. 과적합이다.
데이터 증강의 핵심 아이디어는 하나의 이미지를 살짝씩 변형해서 여러 버전을 만들면, 모델 입장에서는 마치 더 많은 데이터를 본 것과 같은 효과가 난다. 원본 망막 사진을 좌우로 뒤집고, 상하로 뒤집고, 회전하고, 밝기를 바꾸고, 약간 확대하면, 하나의 사진에서 수십 가지 변형이 나온다. 이 변형들은 모두 같은 등급의 같은 망막이지만, 픽셀 배열은 전부 다르다.모델은 이 변형들을 보면서 “뒤집어도 같은 등급이고, 어두워져도 같은 등급이다"라는 사실을 깨닫게 된다. 특정 이미지의 특정 밝기나 특정 방향에 의존하지 않고, 진짜로 등급을 결정하는 의학적 특징(출혈 반점의 유무, 비정상 혈관의 패턴)에 집중하게 되는 것이다.
#
#2 DRAugment: 커스텀 Keras 레이어
class DRAugment(layers.Layer):
def call(self, inputs, training=True):
parent_tensor = inputs / 255.0 # 0~255 → 0~1 정규화
if not self.augment or not training:
return parent_tensor
보통 데이터 증강은 모델 밖에서 한다. 이미지를 모델에 넣기 전에 따로 전처리 코드를 돌리는 식이다. 그런데 이 코드에서는 DRAugment를 Keras 레이어로 만들어서 모델 안에 내장시켰다. 왜 이렇게 했을까?
가장 큰 이유는 학습과 추론에서 다르게 동작해야 하기 때문이다. 학습할 때는 증강을 적용해서 다양한 변형을 보여줘야 하지만, 실제로 예측할 때(새로운 환자의 사진을 판독할 때)는 원본 이미지를 있는 그대로 넣어야 한다. 예측할 때 이미지를 랜덤으로 뒤집으면 매번 다른 결과가 나올 테니 말이 안 된다.
Keras 레이어로 만들면 training이라는 플래그 하나로 이 전환이 자동으로 이루어진다. model.fit()을 호출하면 training=True가 되어 증강이 켜지고, model.predict()를 호출하면 training=False가 되어 증강이 꺼진다. 개발자가 따로 신경 쓸 필요가 없다.
또 하나의 이점은 정규화가 모델에 포함된다는 것이다. 코드의 첫 줄에서 inputs / 255.0을 한다. 원본 이미지의 픽셀값은 0-255 범위인데, 신경망은 0-1 범위의 작은 숫자를 다룰 때 학습이 훨씬 안정적이다. 이 나눗셈을 모델 안에 넣어두면, 모델을 저장하고 나중에 불러올 때 별도로 정규화 코드를 챙기지 않아도 된다. 모델 자체가 “나한테는 원본 이미지를 그대로 넣어줘, 내가 알아서 정규화할게"라고 말하는 셈이다.
def preprocess(img):
img = tf.image.random_flip_left_right(img) # 좌우 뒤집기
img = tf.image.random_flip_up_down(img) # 상하 뒤집기
img = tf.image.rot90(img, k=np.random.randint(0, 4)) # 90° 회전 0~3회
첫 번째 증강은 기하학적 변형이다. 좌우 뒤집기, 상하 뒤집기, 그리고 90도 단위의 회전이다.
이 변형들이 망막 이미지에 특히 적합한 이유가 있다. 망막은 본질적으로 방향성이 없다. 안저 카메라로 찍을 때 카메라가 어떤 각도로 기울어졌느냐에 따라 같은 망막도 다르게 찍힐 수 있다. 왼쪽 눈과 오른쪽 눈은 좌우 대칭이다. 출혈 반점이 사진의 왼쪽에 있든 오른쪽에 있든 같은 등급이다. 따라서 이미지를 뒤집거나 회전해도 의학적 의미가 전혀 변하지 않는다. 등급 3인 망막을 좌우로 뒤집어도 여전히 등급 3이다.
이건 모든 이미지에 해당하는 이야기가 아니다. 예를 들어 글자가 적힌 문서 이미지를 좌우로 뒤집으면 글자가 거울상이 되어 완전히 다른 의미가 된다. 숫자 6을 180도 회전하면 9가 된다. 이런 경우에는 뒤집기나 회전을 증강으로 쓸 수 없다. 망막 이미지는 이런 방향 의존성이 없기 때문에 기하학적 변형을 마음껏 쓸 수 있는 것이다.
rot90에서 k=np.random.randint(0, 4)는 0도, 90도, 180도, 270도 중 하나를 랜덤하게 고른다는 뜻이다. 좌우 뒤집기(2가지) × 상하 뒤집기(2가지) × 회전(4가지)의 조합으로 기하학적 변형만으로도 하나의 이미지에서 최대 16가지 변형을 만들어낼 수 있다.
if self.distort_color:
img = tf.image.random_brightness(img, max_delta=32./255.)
img = tf.image.random_saturation(img, lower=0.5, upper=1.5)
img = tf.clip_by_value(img, 0.0, 1.0)
두 번째 증강은 색상 왜곡이다. 밝기를 ±32/255 범위에서 랜덤하게 바꾸고, 채도를 0.5배에서 1.5배 사이로 랜덤하게 조절한다.
왜 이게 필요할까? 현실에서 안저 사진의 색상은 촬영 조건에 따라 크게 달라진다. 카메라 장비가 다르면 색감이 다르고, 조명 강도에 따라 밝기가 다르고, 환자의 눈 색소량에 따라 전체적인 톤이 다르다. 같은 등급의 같은 병변이어도 사진마다 색이 다른 것이다.
색상 왜곡은 이런 현실적 변동을 인위적으로 만들어낸다. 모델이 “이 사진이 좀 어두운데 등급 2다” “이 사진은 밝은데 역시 등급 2다” “이건 채도가 낮은데 등급 2다"라는 다양한 버전을 보면서, 색상이 아닌 구조적 특징(출혈의 형태, 혈관의 비정상적 패턴)에 집중하게 된다.
random_brightness의 max_delta=32/255는 약 12.5%의 밝기 변동이다. 너무 크면 이미지가 하얗게 날아가거나 까맣게 뭉개져서 정보가 손실되고, 너무 작으면 증강 효과가 미미하다. 32/255는 눈에 띄는 변화이면서도 이미지의 의학적 정보를 파괴하지 않는 적절한 범위다. random_saturation의 0.5~1.5 범위도 비슷한 논리다. 채도를 0.5배로 낮추면 색이 빠져서 약간 회색빛이 되고, 1.5배로 높이면 색이 과장된다. 두 극단 모두 현실적으로 촬영 장비 차이에서 충분히 발생할 수 있는 범위다.
clip_by_value(img, 0.0, 1.0)은 안전장치다. 밝기를 올리거나 채도를 높이면 일부 픽셀값이 1.0을 초과하거나 0.0 미만으로 떨어질 수 있다. 이런 비정상적인 값을 0~1 범위 안으로 잘라주는 것이다. 이미지 데이터에서 이 범위를 벗어나는 값은 의미가 없으므로 반드시 잘라줘야 한다.
if self.central_crop:
img = tf.image.central_crop(img,
np.clip(np.random.normal(1., 0.06), 0.8, 1.))
img = tf.image.resize(
tf.expand_dims(img, 0),
tf.convert_to_tensor(self.size))[0]
세 번째 증강은 중앙 크롭이다. 이미지의 중앙 80%-100%를 랜덤하게 잘라낸 다음, 다시 원래 크기(512×512)로 리사이즈한다.
이걸 이해하려면 이전 단계의 전처리를 떠올려야 한다. data.py에서 Canny 엣지 검출로 망막 중심을 찾아서 정사각형으로 잘랐다. 하지만 이 중심 추정이 항상 완벽한 건 아니다. 몇 픽셀 정도는 어긋날 수 있다. 그리고 실제 진료 현장에서도 카메라 위치가 미세하게 다르면 망막이 사진 안에서 약간씩 다른 위치에 찍힌다. 중앙 크롭은 이런 미세한 위치 변동을 시뮬레이션한다. 100%로 크롭하면 원본 그대로이고, 80%로 크롭하면 가장자리 10%씩이 잘려나가면서 약간 확대된 효과가 난다. 이걸 랜덤하게 반복하면 모델은 “망막이 화면에서 약간 왼쪽으로 치우쳐 있어도, 약간 확대되어 보여도 같은 등급이다"라는 걸 배우게 된다.
크롭 비율을 정하는 방식이 재미있다. np.random.normal(1.0, 0.06)은 평균 1.0, 표준편차 0.06인 정규분포에서 숫자를 뽑는다. 대부분의 경우 0.94-1.06 범위의 값이 나온다. 즉 대체로 원본에 가깝되 살짝만 다른 크롭을 만든다. 가끔 극단적인 값이 나올 수 있으니 np.clip으로 0.8-1.0 범위를 벗어나지 못하게 제한한다. 1.0보다 큰 값은 의미가 없으므로(이미지 바깥을 볼 수는 없으니까) 상한을 1.0으로 둔 것이고, 0.8보다 작으면 너무 많이 잘려서 중요한 정보가 사라질 수 있으므로 하한을 0.8으로 둔 것이다.
크롭 후에는 이미지 크기가 줄어들었으므로 tf.image.resize로 다시 512×512로 복원한다. 여기서 tf.expand_dims로 차원을 하나 추가하는 건 resize 함수가 배치 차원을 요구하기 때문이고, [0]으로 다시 빼주는 건 resize 후에 추가된 차원을 제거하기 위해서다. 순수한 기술적 요구사항이다.
return tf.map_fn(preprocess, parent_tensor)
마지막 줄의 tf.map_fn(preprocess, parent_tensor)은 배치 안의 모든 이미지에 preprocess 함수를 하나씩 적용한다는 뜻이다. 이게 중요한 이유는 각 이미지에 서로 다른 랜덤 변형이 적용되어야 하기 때문이다.
만약 배치 전체에 같은 변형을 적용하면(예를 들어 1,000장 모두 똑같이 좌우 반전), 모델은 “이번 배치는 전부 뒤집힌 거구나"라는 배치 수준의 패턴을 잡아버릴 수 있다. 각 이미지가 독립적으로 랜덤하게 변형되어야 모델이 개별 이미지의 의학적 특징에 집중할 수 있다.
#
#3 정리
학습 중에 하나의 망막 이미지가 DRAugment 레이어를 통과할 때 벌어지는 일은 이렇다. 먼저 0-255 범위의 픽셀값이 0-1로 정규화된다. 그 다음 좌우 뒤집기, 상하 뒤집기, 90도 회전이 랜덤하게 적용되어 기하학적 변형이 만들어진다. 밝기와 채도가 랜덤하게 조절되어 다양한 촬영 조건이 시뮬레이션된다. 마지막으로 중앙 부분이 랜덤한 비율로 크롭되어 미세한 위치 변동이 반영된다. 매 에포크마다 같은 이미지에 다른 랜덤 변형이 적용되므로, 모델은 200 에포크 동안 사실상 같은 이미지를 두 번 보지 않는 셈이다.
추론할 때는 이 모든 랜덤 과정이 꺼지고, 정규화만 적용된 깨끗한 이미지가 모델에 들어간다. 학습 때는 일부러 어렵고 다양한 조건을 주어서 단련시키고, 시험 때는 정석대로 평가하는 것이다.