이미지 분할과 물체 탐지
Part 1에서 다룬 합성곱 신경망(CNN)은 이미지 전체에 하나의 라벨을 붙이는 분류 문제에 초점을 맞췄습니다. 그러나 실제 시각 인식 과제에서는 "이 사진이 고양이인가"를 넘어 "고양이가 어디에, 어떤 모양으로 있는가"를 알아야 하는 경우가 많습니다. 이 장에서는 픽셀 단위로 영역을 구분하는 이미지 분할(image segmentation) 과, 객체의 위치를 사각형으로 찾아내는 물체 탐지(object detection) 를 다룹니다.
이미지 분할은 입력 이미지의 모든 픽셀에 클래스를 부여합니다. 따라서 출력 역시 입력과 같은 공간 해상도를 가진 이미지(마스크) 형태가 됩니다. 이를 위해 CNN을 인코더-디코더(encoder-decoder) 구조로 확장합니다. 인코더는 합성곱과 다운샘플링으로 특징을 압축하고, 디코더는 전치 합성곱(transposed convolution)으로 다시 원래 해상도까지 복원하면서 픽셀별 예측을 만들어 냅니다.
실습 데이터로는 Oxford-IIIT Pet 데이터셋을 사용합니다. 반려동물 사진과, 각 픽셀이 전경(동물)·배경·경계 중 어디에 속하는지를 표시한 분할 마스크가 짝으로 제공됩니다.
1Oxford-IIIT Pet 데이터셋¶
먼저 데이터 디렉터리에서 원본 이미지(.jpg)와 분할 마스크(.png)의 경로 목록을 모읍니다. 이미지와 마스크는 파일 이름(확장자 제외)이 동일하게 짝지어져 있으므로, 양쪽을 정렬한 뒤 stem이 일치하는지 확인해 데이터 정합성을 검증합니다.
from pathlib import Path
data_dir = Path('data/oxford-iiit-pet/')
assert data_dir.exists()
image_files = list((data_dir / 'images').glob('**/*.jpg'))
print(len(image_files))
mask_files = list((data_dir / 'annotations'/ 'trimaps').glob('**/*.png'))
print(len(mask_files))
image_files.sort()
mask_files.sort()
for file1, file2 in zip(image_files, mask_files):
# print(file1.stem, file2.stem)
assert file1.stem == file2.stem, f'{file1.name} != {file2.name}'원본 이미지 한 장을 열어 크기와 색상 모드를 확인합니다. 사진은 일반적인 RGB 컬러 이미지입니다.
from PIL import Image
file1 = image_files[0]
print(file1)
sample = None
with Image.open(file1) as img:
sample = img.copy()
print(sample.size, sample.mode)
display(sample)이번에는 같은 이름의 마스크 파일을 살펴봅니다. 마스크는 트라이맵(trimap) 으로, 픽셀 값이 1, 2, 3 세 가지뿐입니다. 각각 전경(동물)·배경·경계 영역을 의미합니다. 값의 차이가 작아 그대로 보면 거의 검게 보이므로, 시각화할 때는 값에 상수를 곱해 대비를 키워 표시합니다.
from PIL import Image
file1 = mask_files[0]
print(file1)
sample = None
with Image.open(file1) as img:
sample = img.copy()
print(sample.size, sample.mode)
# display(sample)
sample = np.array(sample)
print(sample.shape)
print(np.unique(sample))
print(f'{sample.min()} ~ {sample.max()}')
plt.imshow(sample * 50)
plt.show()1.1데이터셋 클래스 정의¶
이미지와 마스크를 한 쌍으로 묶어 모델에 공급하기 위해 데이터셋 클래스를 정의합니다. 핵심 처리는 다음과 같습니다.
이미지:
convert('RGB')로 색상 모드를 통일하고, 지정한img_size로 리사이즈합니다.마스크: 흑백(
'L')으로 변환·리사이즈한 뒤, 픽셀 값1, 2, 3을0, 1, 2로 1만큼 빼서 클래스 인덱스로 만듭니다. 손실 함수가 0부터 시작하는 클래스 인덱스를 기대하기 때문입니다. 마지막으로 채널 차원을 추가합니다.
파일 이름이 어긋나는 쌍은 경고를 출력하고 건너뛰어, 잘못 짝지어진 데이터가 학습에 섞이지 않도록 합니다.
# %load oxfordpet.py
from PIL import Image
class OxfordPetDataset:
def __init__(self, image_files, mask_files, img_size, transforms=None):
self.img_size = img_size
# 확인
assert len(image_files) == len(mask_files)
self.image_files = []
self.mask_files = []
for file1, file2 in zip(image_files, mask_files):
try:
assert file1.stem == file2.stem
self.image_files.append(file1)
self.mask_files.append(file2)
except AssertionError as e:
print(f'경고! {file1.name} != {file2.name}')
self.transforms = transforms
def __len__(self):
return len(self.image_files)
def __getitem__(self, 번호):
sample = None
with Image.open(self.image_files[번호]) as image:
sample = image.convert('RGB').resize(self.img_size).copy()
mask = None
with Image.open(self.mask_files[번호]) as annotation:
mask = np.array(annotation.convert('L').resize(self.img_size))
# 1, 2, 3 -> 0, 1, 2
mask -= 1
# 채널 차원 추가
mask = np.expand_dims(mask, axis=-1)
if self.transforms:
sample = self.transforms(sample)
return sample, mask전체 데이터를 학습용과 평가용으로 나눕니다. 테스트 셋으로 1,000장을 떼어 두고 나머지를 학습에 사용합니다.
from sklearn.model_selection import train_test_split
train_images, test_images, train_masks, test_masks = train_test_split(
image_files, mask_files, test_size=1000, shuffle=True, random_state=0
)
print(len(train_images), len(test_images))앞서 정의한 데이터셋 클래스로 학습/테스트 데이터셋을 생성합니다. 전처리 함수는 이미지를 NumPy 배열의 float32 타입으로 변환합니다. 입력 크기는 으로 고정했습니다. 표본 하나를 꺼내 이미지는 (200, 200, 3), 마스크는 클래스 인덱스 0, 1, 2를 담고 있음을 확인합니다.
img_size = (200, 200)
전처리 = lambda sample: np.array(sample).astype('float32')
datasets = {}
datasets['train'] = OxfordPetDataset(
train_images, train_masks, img_size=img_size, transforms=전처리)
datasets['test'] = OxfordPetDataset(
test_images, test_masks, img_size=img_size, transforms=전처리)
for split in datasets.keys():
print(f'{split} {len(datasets[split])}')
# for sample, mask in datasets[split]:
# assert sample.shape == img_size + (3,)
# assert mask.shape == img_size
sample, mask = datasets['train'][0]
print(type(sample), sample.shape, sample.dtype)
print(type(mask), mask.shape, mask.dtype)
print(np.unique(mask))
display(transforms.ToPILImage()(sample.astype('uint8')))데이터로더로 배치를 구성합니다. 배치 크기 32로, 이미지 배치와 마스크 배치가 함께 묶여 나오는 것을 확인합니다.
batch_size = 32
dataloaders = get_data_loader(datasets, batch_size=batch_size, num_workers=4)
print(dataloaders.keys())
for batch_images, batch_masks in dataloaders['train']:
print(batch_images.shape, batch_masks.shape)
break2분할 모형: 인코더-디코더 구조¶
분할 모델은 인코더-디코더 형태의 완전 합성곱 신경망(fully convolutional network)입니다. 전체 흐름은 다음과 같습니다.
| 단계 | 구성 | 역할 |
|---|---|---|
| 입력 정규화 | Rescaling(1/255) | 픽셀 값을 로 |
| 인코더 | Conv2D (filters 64→128→256), strides=2로 다운샘플링 | 특징 추출·공간 압축 |
| 디코더 | Conv2DTranspose (filters 256→128→64), strides=2로 업샘플링 | 해상도 복원 |
| 출력층 | Conv2D filters=3 | 픽셀별 3개 클래스 점수 |
인코더에서는 strides=2 합성곱으로 특징 맵의 가로·세로를 절반씩 줄이며 의미적 특징을 모읍니다. 디코더에서는 전치 합성곱으로 다시 두 배씩 키워 입력과 같은 해상도를 회복합니다. 마지막 출력층은 채널이 3개(클래스 수)인 합성곱으로, 각 픽셀마다 세 클래스에 대한 점수를 내보냅니다.
손실 함수로는 sparse_categorical_crossentropy를 사용합니다. 마스크가 원-핫이 아니라 정수 클래스 인덱스 형태이기 때문입니다. 픽셀마다 다중 분류를 수행하는 셈입니다.
from keras import layers
def build_model(inputs, name=None):
model = keras.Sequential(name=name)
model.add(inputs)
model.add(layers.Rescaling(1 / 255))
# encoder
for filters in [64, 128, 256]:
model.add(layers.Conv2D(
filters=filters, kernel_size=3, strides=2, padding='same', activation='relu'))
model.add(layers.Conv2D(
filters=filters, kernel_size=3, strides=1, padding='same', activation='relu'))
# decoder
for filters in reversed([64, 128, 256]):
model.add(layers.Conv2DTranspose(
filters=filters, kernel_size=3, strides=1, padding='same', activation='relu'))
model.add(layers.Conv2DTranspose(
filters=filters, kernel_size=3, strides=2, padding='same', activation='relu'))
# 출력층: 영역 분할 이미지 생성
model.add(layers.Conv2D(filters=3, kernel_size=3, padding='same', name='segmentation'))
return model
keras.backend.clear_session()
model = build_model(keras.Input(shape=(200, 200, 3,)), name='segmentation_model')
model.summary()
model.compile(
loss=keras.losses.sparse_categorical_crossentropy,
optimizer='rmsprop',
)2.1모델 훈련과 학습 곡선¶
분할 모델 학습은 계산량이 크므로 별도 스크립트로 실행한 뒤, 기록된 학습 로그를 불러와 손실 곡선을 확인합니다.
모델 훈련
conda activate pytorch
python train_segmentation.pyresults = pd.read_csv('oxford_segmentation.csv')
display(results.tail())
results.plot()
plt.show()학습이 끝난 모델을 불러와 학습/테스트 데이터에서 평가합니다. 저장된 모델 파일을 로드한 뒤 각 분할(split)에 대해 손실을 측정합니다.
model = keras.models.load_model('oxford_segmentation.keras')
scores = {}
for split in dataloaders.keys():
scores[split] = model.evaluate(dataloaders[split], return_dict=True)
pd.DataFrame(scores).round(3)2.2새로운 이미지에 대한 예측¶
학습 데이터에 없던 새 사진으로 분할을 수행해 봅니다. 먼저 RGB로 변환하고 모델 입력 크기로 리사이즈합니다.
모찌 = None
with Image.open('mozzi.jpg') as img:
모찌 = img.convert('RGB').resize((200, 200)).copy()
display(모찌)입력을 배치 형태로 만들어 모델에 통과시킵니다. 출력은 픽셀마다 3개 클래스의 점수이므로, 마지막 축에 대해 argmax를 취해 각 픽셀의 클래스 인덱스를 얻습니다. 이렇게 생성된 분할 마스크를 원본 위에 반투명하게 겹쳐 보면, 모델이 동물 영역과 배경을 픽셀 단위로 구분해 낸 결과를 시각적으로 확인할 수 있습니다.
x1 = np.array(모찌)
X_batch = np.stack([x1])
print(X_batch.shape)
model = keras.models.load_model('oxford_segmentation.keras')
outputs = model.predict(X_batch)
생성이미지 = np.argmax(outputs, axis=-1).squeeze()
print(생성이미지.shape)
print(np.unique(생성이미지))
plt.subplot(1, 2, 1)
plt.imshow(x1)
plt.imshow(생성이미지, alpha=0.4, vmin=0, vmax=2)
plt.subplot(1, 2, 2)
plt.imshow(생성이미지, vmin=0, vmax=2, cmap="gray")
plt.show()3사전학습 모델로 확장하기¶
앞에서는 분할 모델을 처음부터 직접 학습했지만, 실무에서는 대규모로 사전학습된 모델을 그대로 쓰거나 미세 조정해 활용하는 경우가 많습니다. 두 가지 대표적인 방향을 짚습니다.
4이미지 분할¶
SAM (Segment Anything Model): 제로샷 (Zero-Shot)
이미지 분할 쪽에서는 SAM(Segment Anything Model)이 대표적입니다. 별도 학습 없이도 임의의 이미지에서 객체 영역을 분리해 내는 제로샷(zero-shot) 분할이 가능합니다. 점이나 박스 같은 간단한 프롬프트만으로 원하는 영역의 마스크를 즉시 얻을 수 있습니다.
5물체 탐지¶
YOLO (You Only Look Once): 미세 조정/응용 출력
물체 탐지는 픽셀 단위 마스크 대신 객체를 둘러싸는 경계 상자(bounding box) 와 클래스를 예측합니다. YOLO(You Only Look Once)는 이미지를 한 번만 통과시켜 여러 객체의 위치와 종류를 동시에 출력하는 실시간 탐지 모델입니다. 사전학습된 YOLO를 그대로 응용하거나, 특정 도메인 데이터로 미세 조정(fine-tuning) 하여 맞춤형 탐지기를 만들 수 있습니다.
정리하면, 분할은 "어떤 픽셀이 무엇인가"를, 탐지는 "어떤 상자 안에 무엇이 있는가"를 답합니다. 두 과제 모두 Part 1의 CNN을 토대로 인코더-디코더 또는 멀티헤드 출력 구조로 확장한 것이며, 오늘날에는 SAM·YOLO 같은 강력한 사전학습 모델을 활용해 적은 노력으로 높은 성능을 얻을 수 있습니다.