인스턴스 분할 — Mask R-CNN

Faster R-CNN 탐지기에 작은 마스크 분기(mask branch)를 붙이면 인스턴스 세그멘테이션이 됩니다. 어려운 부분은 RoIAlign이고, 보기보다 더 까다롭습니다.

유형: Build + Learn 언어: Python 선수 학습: Phase 4 Lesson 06 (YOLO), Phase 4 Lesson 07 (U-Net) 소요 시간: 약 75분

학습 목표

  • Mask R-CNN 아키텍처를 백본(backbone), FPN, RPN, RoIAlign, 박스 헤드(box head), 마스크 헤드(mask head)까지 종단 간(end-to-end)으로 추적합니다.
  • RoIAlign을 직접 구현하고, RoIPool이 더 이상 쓰이지 않는 이유를 설명합니다.
  • torchvision의 사전학습 모델인 maskrcnn_resnet50_fpn_v2를 사용해 프로덕션 수준의 인스턴스 마스크를 만들고, 출력 형식을 정확히 읽습니다.
  • 박스 헤드와 마스크 헤드를 교체하고 백본을 동결(freeze)하여 작은 사용자 정의 데이터셋(custom dataset)에서 Mask R-CNN을 미세조정(fine-tuning)합니다.

문제

의미론적 세그멘테이션(Semantic Segmentation)은 클래스마다 하나의 마스크를 줍니다. 인스턴스 세그멘테이션(Instance Segmentation)은 두 객체가 같은 클래스에 속하더라도 객체마다 하나의 마스크를 줍니다. 개별 객체를 세거나, 프레임 사이에서 추적하거나, 벽돌 하나하나의 경계 상자(bounding box) 또는 현미경 이미지의 세포 하나하나를 측정하려면 인스턴스 세그멘테이션이 필요합니다.

Mask R-CNN(He et al., 2017)은 인스턴스 세그멘테이션을 탐지에 마스크를 더한 문제로 재구성해 이 문제를 풀었습니다. 설계가 매우 깔끔했기 때문에 이후 5년 동안 거의 모든 인스턴스 세그멘테이션 논문은 Mask R-CNN의 변형이었고, torchvision 구현은 지금도 소규모에서 중간 규모 데이터셋의 프로덕션 기본값으로 자주 쓰입니다.

어려운 공학 문제는 샘플링입니다. 꼭짓점이 픽셀 경계와 맞지 않는 후보 상자(proposal box)에서 고정 크기 특징 영역(feature region)을 어떻게 잘라낼까요? 이 부분을 잘못 처리하면 mAP가 곳곳에서 0.1점 단위로 떨어집니다. RoIAlign이 그 답입니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.Mask R-CNN이 RoIPool을 RoIAlign으로 바꾼 이유는 무엇인가요?

2.Mask R-CNN의 마스크 헤드(mask head)는 후보(proposal)마다 클래스별 28x28 마스크를 출력합니다. 왜 클래스별로 출력할까요?

0/2 답변 완료

개념

아키텍처

flowchart LR
    IMG["입력"] --> BB["ResNet<br/>backbone"]
    BB --> FPN["Feature<br/>Pyramid Network"]
    FPN --> RPN["Region<br/>Proposal<br/>Network"]
    FPN --> RA["RoIAlign"]
    RPN -->|"top-K proposals"| RA
    RA --> BH["Box head<br/>(class + refine)"]
    RA --> MH["Mask head<br/>(14x14 conv)"]
    BH --> NMS["NMS"]
    MH --> NMS
    NMS --> OUT["boxes +<br/>classes + masks"]

    style BB fill:#dbeafe,stroke:#2563eb
    style FPN fill:#fef3c7,stroke:#d97706
    style RPN fill:#fecaca,stroke:#dc2626
    style OUT fill:#dcfce7,stroke:#16a34a

이해해야 할 구성 요소는 다섯 가지입니다.

  1. 백본(Backbone) — ImageNet으로 학습한 ResNet-50 또는 ResNet-101입니다. 보폭(stride) 4, 8, 16, 32의 특징 맵(feature map) 계층을 만듭니다.
  2. FPN(Feature Pyramid Network) — 하향식(top-down) 연결과 측면 연결(lateral connection)을 사용해 모든 단계(level)에 의미 정보가 풍부한 C채널 특징을 제공합니다. 탐지는 객체 크기에 맞는 FPN 단계를 조회합니다.
  3. RPN(Region Proposal Network) — 각 앵커(anchor) 위치에서 "여기에 객체가 있는가?"와 "상자를 어떻게 보정할 것인가?"를 예측하는 작은 합성곱(conv) 헤드입니다. 이미지마다 약 1000개의 후보(proposal)를 만듭니다.
  4. RoIAlign — 어떤 FPN 단계의 어떤 상자에서든 고정 크기, 예를 들어 7x7 크기의 특징 패치(feature patch)를 샘플링합니다. 양선형 샘플링(bilinear sampling)을 쓰며 양자화(quantisation)가 없습니다.
  5. 헤드(Heads) — 상자를 보정하고 클래스를 고르는 두 층짜리 박스 헤드와, 각 후보에 대해 28x28 이진 마스크를 출력하는 작은 합성곱 마스크 헤드입니다.

RoIPool이 아니라 RoIAlign인 이유

원래 Fast R-CNN은 RoIPool을 사용했습니다. RoIPool은 후보 상자를 격자(grid)로 나누고, 각 셀(cell)에서 최대 특징 값을 취하며, 모든 좌표를 정수로 반올림합니다. 이 반올림 때문에 특징 맵이 입력 픽셀 좌표와 최대 한 특징-맵 픽셀만큼 어긋납니다. 224x224 이미지에서는 작아 보일 수 있지만, 보폭 32의 특징 맵에서는 치명적입니다.

RoIPool:
  상자 (34.7, 51.3, 98.2, 142.9)
  반올림 -> (34, 51, 98, 142)
  격자 분할 -> 각 셀 경계도 반올림
  단계마다 어긋남(misalignment)이 누적됨

RoIAlign:
  상자 (34.7, 51.3, 98.2, 142.9)
  양선형 보간(bilinear interpolation)으로 정확한 실수 좌표에서 샘플링
  어디에서도 반올림하지 않음

RoIAlign은 COCO에서 마스크 AP를 비용 없이 3-4점 끌어올렸습니다. 이제 위치 추정(localisation)이 중요한 탐지기(detector)는 YOLOv7 seg, RT-DETR, Mask2Former까지 모두 이 아이디어를 사용합니다.

RPN 한 문단 설명

특징 맵의 모든 위치에 서로 다른 크기와 비율의 K개 앵커 상자(anchor box)를 둡니다. 각 앵커마다 객체성 점수(objectness score)와 앵커를 더 잘 맞는 상자로 바꾸는 회귀 보정값(regression offset)을 예측합니다. 점수 상위 약 1000개 상자를 남기고, IoU 0.7에서 NMS를 적용한 뒤 살아남은 후보를 헤드로 넘깁니다. RPN은 자기만의 작은 손실(loss)로 학습됩니다. 구조는 Lesson 6의 YOLO 손실과 비슷하지만, 클래스가 객체(object) / 비객체(no object) 두 개뿐입니다.

마스크 헤드(Mask head)

각 후보에 대해, RoIAlign 뒤의 마스크 헤드는 작은 FCN입니다. 네 개의 3x3 합성곱, 2배 전치 합성곱(deconv), 마지막 1x1 합성곱으로 구성되며 28x28 해상도에서 num_classes개 출력 채널을 만듭니다. 예측 클래스에 해당하는 채널만 사용하고 나머지는 무시합니다. 이렇게 하면 마스크 예측과 분류(classification)가 분리됩니다.

최종 이진 마스크를 만들 때는 28x28 마스크를 후보의 원래 픽셀 크기로 업샘플(upsample)합니다.

손실

Mask R-CNN은 네 종류의 손실을 더합니다.

L = L_rpn_cls + L_rpn_box + L_box_cls + L_box_reg + L_mask
  • L_rpn_cls, L_rpn_box — RPN 후보를 위한 객체성 손실과 상자 회귀(box regression) 손실입니다.
  • L_box_cls — 헤드 분류기의 배경 포함 (C+1) 클래스 교차 엔트로피입니다.
  • L_box_reg — 헤드의 상자 보정(box refinement)에 대한 smooth L1입니다.
  • L_mask — 28x28 마스크 출력에 대한 픽셀 단위 이진 교차 엔트로피입니다.

각 손실에는 기본 가중치가 있으며, torchvision 구현은 이를 생성자 인자(constructor argument)로 노출합니다.

출력 형식

torchvision.models.detection.maskrcnn_resnet50_fpn_v2는 이미지마다 하나의 딕셔너리(dict)를 담은 리스트(list)를 반환합니다.

{
    "boxes":  (N, 4) in (x1, y1, x2, y2) pixel coordinates,
    "labels": (N,) class IDs, 0 = background so indices are 1-based,
    "scores": (N,) confidence scores,
    "masks":  (N, 1, H, W) float masks in [0, 1] — threshold at 0.5 for binary,
}

마스크는 이미 전체 이미지 해상도입니다. 28x28 헤드 출력은 내부에서 업샘플되어 있습니다.

만들어보기

Step 1: RoIAlign 직접 구현

Mask R-CNN의 이 구성 요소는 글보다 코드로 보는 편이 더 이해하기 쉽습니다.

import torch
import torch.nn.functional as F

def roi_align_single(feature, box, output_size=7, spatial_scale=1 / 16.0):
    """
    feature: (C, H, W) 단일 이미지 특징 맵
    box: 원본 이미지 픽셀 좌표계의 (x1, y1, x2, y2)
    output_size: 출력 격자의 한 변 크기(박스 헤드는 7, 마스크 헤드는 14)
    spatial_scale: 특징 맵 보폭의 역수
    """
    C, H, W = feature.shape
    x1, y1, x2, y2 = [c * spatial_scale - 0.5 for c in box]
    bin_w = (x2 - x1) / output_size
    bin_h = (y2 - y1) / output_size

    grid_y = torch.linspace(y1 + bin_h / 2, y2 - bin_h / 2, output_size)
    grid_x = torch.linspace(x1 + bin_w / 2, x2 - bin_w / 2, output_size)
    yy, xx = torch.meshgrid(grid_y, grid_x, indexing="ij")

    gx = 2 * (xx + 0.5) / W - 1
    gy = 2 * (yy + 0.5) / H - 1
    grid = torch.stack([gx, gy], dim=-1).unsqueeze(0)
    sampled = F.grid_sample(feature.unsqueeze(0), grid, mode="bilinear",
                            align_corners=False)
    return sampled.squeeze(0)

모든 값은 양선형 샘플링으로 정해진 위치에서 가져옵니다. 반올림도, 양자화도, 기울기(gradient)가 끊기는 지점도 없습니다.

Step 2: torchvision의 RoIAlign과 비교

from torchvision.ops import roi_align

feature = torch.randn(1, 16, 50, 50)
boxes = torch.tensor([[0, 10, 20, 100, 90]], dtype=torch.float32)  # (batch_idx, x1, y1, x2, y2)

ours = roi_align_single(feature[0], boxes[0, 1:].tolist(), output_size=7, spatial_scale=1/4)
theirs = roi_align(feature, boxes, output_size=(7, 7), spatial_scale=1/4, sampling_ratio=1, aligned=True)[0]

print(f"우리 구현 shape:   {tuple(ours.shape)}")
print(f"torchvision shape: {tuple(theirs.shape)}")
print(f"max|diff|:         {(ours - theirs).abs().max().item():.3e}")

sampling_ratio=1aligned=True를 쓰면 두 결과는 1e-5 이내로 맞습니다.

Step 3: 사전학습 Mask R-CNN 불러오기

import torch
from torchvision.models.detection import maskrcnn_resnet50_fpn_v2, MaskRCNN_ResNet50_FPN_V2_Weights

model = maskrcnn_resnet50_fpn_v2(weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
model.eval()
print(f"파라미터 수: {sum(p.numel() for p in model.parameters()):,}")
print(f"클래스 수(배경 포함): {len(model.roi_heads.box_predictor.cls_score.out_features * [0])}")

파라미터는 약 4600만 개이고, COCO 기준 91개 클래스입니다. 첫 클래스(id 0)는 배경이며, 실제로 모델이 탐지하는 클래스는 id 1부터 시작합니다.

Step 4: 추론 실행

with torch.no_grad():
    x = torch.randn(3, 400, 600)
    predictions = model([x])
p = predictions[0]
print(f"boxes:  {tuple(p['boxes'].shape)}")
print(f"labels: {tuple(p['labels'].shape)}")
print(f"scores: {tuple(p['scores'].shape)}")
print(f"masks:  {tuple(p['masks'].shape)}")

마스크 텐서(tensor)는 (N, 1, H, W) 형태(shape)입니다. 객체별 이진 마스크를 얻으려면 0.5에서 임계값(threshold)을 적용합니다.

binary_masks = (p['masks'] > 0.5).squeeze(1)  # (N, H, W) boolean

Step 5: 사용자 정의 클래스 수에 맞게 헤드 교체

일반적인 미세조정 방식은 백본, FPN, RPN은 재사용하고 분류기 헤드(classifier head) 두 개를 교체하는 것입니다.

from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor

def build_custom_maskrcnn(num_classes):
    model = maskrcnn_resnet50_fpn_v2(weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
    in_features = model.roi_heads.box_predictor.cls_score.in_features
    model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)
    in_features_mask = model.roi_heads.mask_predictor.conv5_mask.in_channels
    hidden_layer = 256
    model.roi_heads.mask_predictor = MaskRCNNPredictor(in_features_mask, hidden_layer, num_classes)
    return model

custom = build_custom_maskrcnn(num_classes=5)
print(f"custom cls_score.out_features: {custom.roi_heads.box_predictor.cls_score.out_features}")

num_classes는 반드시 배경 클래스를 포함해야 합니다. 따라서 실제 객체 클래스가 4개인 데이터셋은 num_classes=5를 사용합니다.

Step 6: 학습할 필요 없는 부분 동결

작은 데이터셋에서는 백본과 FPN을 동결합니다. RPN의 객체성/회귀와 두 헤드만 학습합니다.

def freeze_backbone_and_fpn(model):
    # torchvision Mask R-CNN은 FPN을 `model.backbone` 내부(`model.backbone.fpn`)에 담습니다.
    # 따라서 `model.backbone.parameters()`를 순회하면 ResNet 특징 계층과 FPN 측면/출력 합성곱이 모두 포함됩니다.
    for p in model.backbone.parameters():
        p.requires_grad = False
    return model

custom = freeze_backbone_and_fpn(custom)
trainable = sum(p.numel() for p in custom.parameters() if p.requires_grad)
print(f"동결 후 학습 가능 파라미터 수: {trainable:,}")

500장 규모의 데이터셋에서는 이것이 수렴과 과적합(overfitting)을 가르는 차이가 될 수 있습니다.

활용하기

torchvision에서 Mask R-CNN의 전체 학습 루프는 약 40줄이며, 과제마다 의미 있게 바뀌지 않습니다. 데이터셋만 바꾸면 됩니다.

def train_step(model, images, targets, optimizer):
    model.train()
    loss_dict = model(images, targets)
    losses = sum(loss for loss in loss_dict.values())
    optimizer.zero_grad()
    losses.backward()
    optimizer.step()
    return {k: v.item() for k, v in loss_dict.items()}

targets 리스트는 이미지별 딕셔너리를 가져야 하며, 각 딕셔너리에는 boxes, labels, (num_instances, H, W) 이진 텐서인 masks가 들어갑니다. 모델은 학습 중에는 네 종류의 손실 딕셔너리를 반환하고, 평가 중에는 예측(prediction) 리스트를 반환합니다. 이 동작은 model.training에 의해 결정됩니다.

pycocotools 평가기(evaluator)는 상자와 마스크 각각에 대해 mAP@IoU=0.5:0.95를 계산합니다. 박스 헤드가 병목인지 마스크 헤드가 병목인지 알려면 두 숫자가 모두 필요합니다.

산출물 만들기

이 레슨의 최종 산출물은 다음과 같습니다.

  • outputs/prompt-instance-vs-semantic-router.md — 세 가지 질문을 던진 뒤 인스턴스, 의미론적, 팬옵틱 세그멘테이션 중 무엇을 선택할지와 시작 모델을 고르는 프롬프트입니다.
  • outputs/skill-mask-rcnn-head-swapper.md — 새 num_classes를 받아 torchvision 탐지 모델의 헤드를 교체하는 코드를 생성하는 스킬(skill)입니다.

연습문제

  1. (쉬움) 100개의 무작위 상자에서 직접 구현한 RoIAlign을 torchvision.ops.roi_align과 비교합니다. 최대 절대 차이를 보고합니다. RoIPool, 즉 2017년 이전 동작도 실행해 경계 근처 상자에서 약 1-2 특징-맵 픽셀만큼 어긋나는지 보여줍니다.
  2. (중간) 50장짜리 사용자 정의 데이터셋에서 maskrcnn_resnet50_fpn_v2를 미세조정합니다. 클래스는 풍선, 물고기, 포트홀, 로고 등 아무 두 개면 됩니다. 백본을 동결하고 20 에포크(epoch) 학습한 뒤 마스크 AP@0.5를 보고합니다.
  3. (어려움) Mask R-CNN의 마스크 헤드를 28x28 대신 56x56을 예측하도록 바꿉니다. 변경 전후 mAP@IoU=0.75를 측정합니다. 성능 향상 또는 향상 없음이 예상한 경계 정밀도와 메모리의 절충(trade-off)에 맞는지 설명합니다.

핵심 용어

용어흔한 설명실제 의미
Mask R-CNN"탐지 + 마스크(Detection plus masks)"Faster R-CNN에 후보별, 클래스별 28x28 마스크를 예측하는 작은 FCN 헤드를 더한 모델입니다.
FPN(Feature Pyramid Network)"특징 피라미드(Feature pyramid)"하향식 연결과 측면 연결로 모든 보폭 단계에 의미 정보가 풍부한 C채널 특징을 제공합니다.
RPN(Region Proposal Network)"영역 후보 생성기(Region proposer)"이미지마다 약 1000개의 객체/비객체 후보를 만드는 작은 합성곱 헤드입니다.
RoIAlign"반올림 없는 자르기(No-rounding crop)"실수 좌표 상자에서 고정 크기 특징 격자를 양선형 샘플링으로 뽑아냅니다.
RoIPool"2017년 이전 자르기(Pre-2017 crop)"RoIAlign과 목적은 같지만 상자 좌표를 반올림합니다. 현재는 구식입니다.
마스크 AP(Mask AP)"인스턴스 mAP(Instance mAP)"상자 IoU가 아니라 마스크 IoU로 계산한 평균 정밀도(average precision)입니다. COCO 인스턴스 세그멘테이션 지표입니다.
이진 마스크 헤드(Binary mask head)"클래스별 마스크(Per-class mask)"각 후보에 대해 클래스별 이진 마스크를 예측합니다. 추론에서는 예측 클래스의 채널만 사용합니다.
배경 클래스(Background class)"클래스 0(Class 0)""객체 없음"을 나타내는 포괄(catch-all) 클래스입니다. 실제 클래스 인덱스는 1부터 시작합니다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 2개

skill-mask-rcnn-head-swapper

Generate the exact code for swapping box and mask heads on a torchvision Mask R-CNN for a custom num_classes

Skill
prompt-instance-vs-semantic-router

Ask three questions and pick instance vs semantic vs panoptic segmentation plus the first model

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.torchvision의 Mask R-CNN 예측 딕셔너리(prediction dict)에서 `labels`가 0이 아니라 1부터 시작하는 이유는 무엇인가요?

2.500장짜리 데이터셋에서 Mask R-CNN을 미세조정(fine-tuning)했더니 학습 손실은 계속 내려가지만 검증(validation) mAP가 정체됩니다. 가장 먼저 시도할 일은 무엇인가요?

3.Mask R-CNN 내부 FPN은 보폭 4, 8, 16, 32의 네 단계(level: P2, P3, P4, P5)를 갖습니다. 왜 하나의 단계만 쓰지 않을까요?

0/3 답변 완료