Phase 04 - Lesson 08

Segmentacion de Instancias — Mask R-CNN

Agrega una pequena rama de mascara a un detector Faster R-CNN y tienes segmentacion de instancias. La parte dificil es RoIAlign, y es mas dificil de lo que parece.

Type: Build + Learn Languages: Python Prerequisites: Fase 4 Leccion 06 (YOLO), Fase 4 Leccion 07 (U-Net) Time: ~75 minutos

Objetivos de Aprendizaje

  • Trazar la arquitectura de Mask R-CNN de extremo a extremo: backbone, FPN, RPN, RoIAlign, cabeza de caja, cabeza de mascara
  • Implementar RoIAlign desde cero y explicar por que ya no se usa RoIPool
  • Usar el modelo preentrenado maskrcnn_resnet50_fpn_v2 de torchvision para mascaras de instancia de calidad de produccion y leer correctamente el formato de su salida
  • Hacer fine-tuning de Mask R-CNN en un pequeno conjunto de datos personalizado reemplazando las cabezas de caja y de mascara y manteniendo el backbone congelado

El Problema

La segmentacion semantica te da una mascara por clase. La segmentacion de instancias te da una mascara por objeto, incluso cuando dos objetos comparten una clase. Contar individuos, rastrear a traves de cuadros y medir cosas (la caja delimitadora de cada ladrillo en una pared, cada celula en una imagen de microscopio) todo exige segmentacion de instancias.

Mask R-CNN (He et al., 2017) resolvio esto reformulando la segmentacion de instancias como deteccion mas una mascara. El diseno era tan limpio que, durante los siguientes cinco anos, casi todo articulo de segmentacion de instancias fue una variante de Mask R-CNN, y la implementacion de torchvision sigue siendo el estandar de produccion para conjuntos de datos pequenos a medianos.

El problema de ingenieria dificil es el muestreo: como recortas una region de features de tamano fijo de una caja de propuesta cuyas esquinas no se alinean con los limites de los pixeles? Equivocarse en eso cuesta decimas de punto de mAP en todas partes. RoIAlign es la respuesta.

El Concepto

La arquitectura

flowchart LR
    IMG["Entrada"] --> BB["ResNet<br/>backbone"]
    BB --> FPN["Red de<br/>Piramide de Features"]
    FPN --> RPN["Red de<br/>Propuesta de<br/>Regiones"]
    FPN --> RA["RoIAlign"]
    RPN -->|"top-K propuestas"| RA
    RA --> BH["Cabeza de caja<br/>(clase + refinar)"]
    RA --> MH["Cabeza de mascara<br/>(conv 14x14)"]
    BH --> NMS["NMS"]
    MH --> NMS
    NMS --> OUT["cajas +<br/>clases + mascaras"]

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

Cinco piezas que entender:

  1. Backbone — ResNet-50 o ResNet-101 entrenado en ImageNet. Produce una jerarquia de mapas de features en strides 4, 8, 16, 32.
  2. FPN (Red de Piramide de Features) — conexiones top-down + laterales que dan a cada nivel C canales de features semanticamente ricas. La deteccion consulta el nivel de la FPN que coincide con el tamano del objeto.
  3. RPN (Red de Propuesta de Regiones) — una pequena cabeza conv que, en cada posicion de ancla, predice "hay un objeto aqui?" y "como refino la caja?". Produce ~1000 propuestas por imagen.
  4. RoIAlign — muestrea una region de features de tamano fijo (por ejemplo, 7x7) de cualquier caja en cualquier nivel de la FPN. Muestreo bilineal, sin cuantizacion.
  5. Cabezas — cabeza de caja de dos capas que refina la caja y elige una clase, mas una pequena cabeza conv que produce una mascara binaria 28x28 para cada propuesta.

Por que RoIAlign, y no RoIPool

El Fast R-CNN original usaba RoIPool, que divide una caja de propuesta en una cuadricula, toma la feature maxima en cada celda y redondea todas las coordenadas a enteros. Ese redondeo desalinea el mapa de features de las coordenadas de pixel de entrada hasta en un pixel completo del mapa de features — pequeno en una imagen de 224x224, catastrofico cuando el mapa de features es de stride 32.

RoIPool:
  box (34.7, 51.3, 98.2, 142.9)
  round -> (34, 51, 98, 142)
  split grid -> round each cell boundary
  misalignment accumulates at every step

RoIAlign:
  box (34.7, 51.3, 98.2, 142.9)
  sample at exact float coordinates using bilinear interpolation
  no rounding anywhere

RoIAlign aumenta el AP de mascara en 3-4 puntos en COCO gratis. Todo detector que se preocupa por la localizacion ahora lo usa — YOLOv7 seg, RT-DETR, Mask2Former, todos por igual.

La RPN en un parrafo

En cada posicion de un mapa de features, coloca K cajas de ancla de diferentes tamanos y formas. Predice una puntuacion de objetividad para cada ancla y un desplazamiento de regresion para convertir el ancla en una caja mejor ajustada. Conserva las ~1.000 mejores cajas por puntuacion, aplica NMS en IoU 0.7 y entrega los sobrevivientes a las cabezas. La RPN se entrena con su propia mini-perdida — la misma estructura que la perdida de YOLO de la Leccion 6, solo que con dos clases (objeto / no objeto).

La cabeza de mascara

Para cada propuesta (despues de RoIAlign) la cabeza de mascara es una pequena FCN: cuatro convs 3x3, un deconv 2x, una conv final 1x1 que produce num_classes canales de salida en resolucion 28x28. Solo se conserva el canal correspondiente a la clase predicha; los demas se ignoran. Esto desacopla la prediccion de mascara de la clasificacion.

Haz upsample de la mascara 28x28 al tamano original en pixeles de la propuesta para producir la mascara binaria final.

Perdidas

Mask R-CNN tiene cuatro perdidas sumadas:

L = L_rpn_cls + L_rpn_box + L_box_cls + L_box_reg + L_mask
  • L_rpn_cls, L_rpn_box — objetividad + regresion de caja para las propuestas de la RPN.
  • L_box_cls — entropia cruzada sobre (C+1) clases (incluyendo el fondo) en el clasificador de la cabeza.
  • L_box_reg — smooth L1 en el refinamiento de caja de la cabeza.
  • L_mask — entropia cruzada binaria por pixel en la salida de la mascara 28x28.

Cada perdida tiene su propio peso por defecto; la implementacion de torchvision los expone como argumentos del constructor.

Formato de salida

torchvision.models.detection.maskrcnn_resnet50_fpn_v2 devuelve una lista de dicts, uno por imagen:

{
    "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,
}

La mascara ya esta en la resolucion completa de la imagen. La salida 28x28 de la cabeza fue upsampled internamente.

Construyelo

Paso 1: RoIAlign desde cero

Este es el unico componente de Mask R-CNN que es mas simple de entender como codigo que como prosa.

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) single-image feature map
    box: (x1, y1, x2, y2) in original image pixel coordinates
    output_size: side of the output grid (7 for box head, 14 for mask head)
    spatial_scale: reciprocal of the feature map stride
    """
    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)

Cada numero esta en una posicion muestreada bilinealmente. Sin redondeo, sin cuantizacion, sin gradientes descartados.

Paso 2: Compara con el RoIAlign de torchvision

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 ours:   {tuple(ours.shape)}")
print(f"shape theirs: {tuple(theirs.shape)}")
print(f"max|diff|:    {(ours - theirs).abs().max().item():.3e}")

Con sampling_ratio=1 y aligned=True, los dos coinciden dentro de 1e-5.

Paso 3: Carga un Mask R-CNN preentrenado

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"params: {sum(p.numel() for p in model.parameters()):,}")
print(f"classes (including background): {len(model.roi_heads.box_predictor.cls_score.out_features * [0])}")

46M de parametros, 91 clases (COCO). La primera clase (id 0) es el fondo; todo lo que el modelo realmente detecta empieza en el id 1.

Paso 4: Ejecuta la inferencia

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)}")

El tensor de mascara tiene forma (N, 1, H, W). Aplica un umbral de 0.5 para obtener una mascara binaria por objeto:

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

Paso 5: Cambia las cabezas por un conteo de clases personalizado

La receta comun de fine-tuning: reutiliza el backbone, la FPN y la RPN; reemplaza las dos cabezas clasificadoras.

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 debe incluir la clase de fondo, asi que un conjunto de datos con 4 clases de objeto usa num_classes=5.

Paso 6: Congela lo que no necesita entrenamiento

En conjuntos de datos pequenos, congela el backbone y la FPN. Solo la objetividad + regresion de la RPN y las dos cabezas aprenden.

def freeze_backbone_and_fpn(model):
    # torchvision Mask R-CNN packs the FPN inside `model.backbone` (as
    # `model.backbone.fpn`), so iterating `model.backbone.parameters()` covers
    # both the ResNet feature layers and the FPN lateral/output convs.
    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 after freeze: {trainable:,}")

En conjuntos de datos de 500 imagenes, esta es la diferencia entre convergencia y overfitting.

Usalo

El loop de entrenamiento completo para Mask R-CNN en torchvision tiene 40 lineas y no cambia de forma significativa entre tareas — cambia los conjuntos de datos y adelante.

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()}

La lista targets debe tener dicts por imagen con boxes, labels y masks (como tensores binarios (num_instances, H, W)). El modelo devuelve un dict de cuatro perdidas durante el entrenamiento y una lista de predicciones durante la evaluacion, segun model.training.

El evaluador pycocotools produce mAP@IoU=0.5:0.95 tanto para cajas como para mascaras; necesitas ambos numeros para saber si la cabeza de caja o la cabeza de mascara es el cuello de botella.

Entregalo

Esta leccion produce:

  • outputs/prompt-instance-vs-semantic-router.md — un prompt que hace tres preguntas y elige entre instancia, semantica o panoptica, mas el modelo exacto con el cual empezar.
  • outputs/skill-mask-rcnn-head-swapper.md — una skill que genera las 10 lineas de codigo para cambiar las cabezas en cualquier modelo de deteccion de torchvision, dado el nuevo num_classes.

Ejercicios

  1. (Facil) Verifica tu RoIAlign contra torchvision.ops.roi_align en 100 cajas aleatorias. Reporta la diferencia absoluta maxima. Tambien ejecuta RoIPool (comportamiento pre-2017) y muestra que diverge en ~1-2 pixeles del mapa de features en cajas cercanas al borde.
  2. (Medio) Haz fine-tuning de maskrcnn_resnet50_fpn_v2 en un conjunto de datos personalizado de 50 imagenes (cualquier par de clases: globos, peces, baches, logos). Congela el backbone, entrena por 20 epocas, reporta el AP de mascara@0.5.
  3. (Dificil) Reemplaza la cabeza de mascara de Mask R-CNN por una que prediga en 56x56 en lugar de 28x28. Mide el mAP@IoU=0.75 antes y despues. Explica por que la ganancia (o la falta de ella) coincide con el trade-off esperado entre precision de borde y memoria.

Terminos Clave

Termino Lo que la gente dice Lo que realmente significa
Mask R-CNN "Deteccion mas mascaras" Faster R-CNN + una pequena cabeza FCN que predice una mascara 28x28 por propuesta por clase
FPN "Piramide de features" Conexiones top-down + laterales que dan a cada nivel de stride C canales de features semanticamente ricas
RPN "Proponente de regiones" Una pequena cabeza conv que produce ~1000 propuestas objeto/no-objeto por imagen
RoIAlign "Recorte sin redondeo" Muestrea bilinealmente una cuadricula de features de tamano fijo de cualquier caja con coordenadas en float
RoIPool "Recorte pre-2017" Mismo proposito que RoIAlign pero redondea coordenadas de caja; obsoleto
Mask AP "mAP de instancia" Precision promedio calculada con IoU de mascara en lugar de IoU de caja; la metrica de segmentacion de instancias de COCO
Cabeza de mascara binaria "Mascara por clase" Predice una mascara binaria por clase para cada propuesta; solo se conserva el canal de la clase predicha
Clase de fondo "Clase 0" La clase comodin "ningun objeto"; los indices para las clases reales empiezan en 1

Lectura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).