跳到主要內容

CV Day 21 Mask R-CNN 實例分割

CV Day 21 Mask R-CNN 實例分割

執行需求:Colab T4 可跑。本篇在 Colab 免費 T4(16 GB VRAM)上用 torchvision 0.20 的 maskrcnn_resnet50_fpn 微調 PASCAL VOC 2012 實例分割子集(每類抽約 50 張訓練、20 張驗證,總計約 200/80 張影像),3 epoch 約 25 分鐘;驗證集上的 mask mAP@0.5 通常可達 0.45–0.55(實際數字會略有不同)。純 CPU 不建議跑(Mask R-CNN 推論也需要 GPU 即時性)。如果你跳過 Day 15 也沒關係——這篇會從頭展示完整的資料準備與訓練流程,並在最後對照 Mask R-CNN 與 Faster R-CNN 的差異。

引言

昨天的內容中,我們用 smp 的 DeepLabV3+、U-Net、FPN 把 VOC 21 類的語意分割推到 mIoU 0.60 以上。語意分割的盲點是「同類別的不同個體會被合併」——例如一張影像上有兩隻貓,語意分割只會輸出一塊「貓」的遮罩,無法分辨這是兩隻獨立的個體。實例分割(Instance Segmentation)補上了這塊缺口,要求模型同時輸出每個物件的「類別、邊界框、像素遮罩」,且不同個體要用不同的 instance ID 區分。實例分割是醫療影像分析(每個腫瘤獨立追蹤)、自駕車(每輛車獨立軌跡預測)、零售盤點(每件商品獨立計數)的核心技術,也是 Day 22 即將介紹的 SAM 提示式分割的前一代標準解。

實例分割最具代表性的模型是 Mask R-CNN(He 等人,2017),它在 Faster R-CNN(Day 15 的兩階段偵測器)基礎上加上一個「mask head」分支:在每個 RoI 上預測一個 28×28 的二值遮罩。Mask R-CNN 看似只是「在 Faster R-CNN 上多加一個分支」,但實作上有兩個關鍵設計:第一,用 RoIAlign 取代 RoIPooling,解決 RoI 量化導致的特徵錯位問題;第二,mask head 的損失只對「正樣本 RoI」計算(ground truth 類別對應的遮罩),並用 sigmoid + binary cross entropy 對每個像素獨立計算,這讓每個 instance 都能得到自己的遮罩預測。本篇會用 torchvision 0.20 內建的 maskrcnn_resnet50_fpn(已在 COCO train2017 上預訓練)做微調,把最後的 box predictor 與 mask predictor 換成 VOC 20 類 + 1 背景。

貫穿專案的角度:MVTec AD 雖然提供二值遮罩(瑕疵 vs 背景),但同一張影像上的多瑕疵需要獨立處理時,Mask R-CNN 就是比 DeepLabV3+ 更合適的工具。Day 25 會用 MVTec AD 做完整的瑕疵分割實戰時,會比較兩種架構的優缺點;今天的重點是把 Mask R-CNN 的 API 與訓練流程搞清楚。實務上 80% 的工業瑕疵場景用實例分割即可,因為同一張影像的瑕疵通常數量少(1–10 個)且需要個別面積統計。

Mask R-CNN 的架構與 RoIAlign

Mask R-CNN 的整體架構可以分成三段。第一段是骨幹(backbone),這裡沿用 ResNet-50 + FPN(Feature Pyramid Network),從影像抽取多尺度特徵圖給第二段使用。第二段是 RPN(Region Proposal Network),在每個尺度特徵圖上預測「可能是物件的候選框」。第三段是 RoI head 與 mask head:對每個候選框做 RoIAlign 取出固定 7×7 的特徵,然後同時做兩件事——用 box head 預測類別與邊界框偏移、用 mask head 預測 28×28 的二值遮罩。預訓練的 COCO 版本有 80 個類別,所以 box_predictor 的輸出是 81(80 類 + 背景)、mask_predictor 的輸出是 80 通道(每個類別一個遮罩)。

RoIAlign 是 Mask R-CNN 與 Faster R-CNN 在實作上最大的差別。Faster R-CNN 用 RoIPooling 把不同尺寸的 RoI 量化成 7×7,這個量化過程會把浮點座標取整,導致 RoI 與實際特徵錯位半個像素。對邊界框影響不大,但對 28×28 的遮罩影響很大——半個像素的錯位在輸出遮罩上會被放大為 4 個像素的誤差。RoIAlign 的解法是「不要量化」,改用雙線性插值取得浮點座標的特徵值;實作上把 RoI 切成 7×7 個 bin,每個 bin 再細分成 4 個子點,用雙線性插值取值後平均,這樣 RoI 的特徵對齊誤差可以壓到 0。torchvision 的 MaskRCNN 內部已經包好 RoIAlign,使用者不用自己寫。

mask head 的設計動機與 FCN 不同。FCN 把整張影像輸出為 N 通道遮罩圖,每個像素的類別由 argmax 決定;mask head 對每個 RoI 獨立輸出 C 通道遮罩(每類一張),最後只取 ground truth 類別對應的那張。這樣設計的關鍵優勢是「避免類別競爭」——FCN 的 argmax 會強迫每個像素只屬於一類,但實例遮罩本來就應該是「這隻貓的像素 vs 另一隻貓的像素」獨立表示。對工業瑕疵來說,這個特性讓多瑕疵獨立計數變得直接。

完整實作:微調 maskrcnn_resnet50_fpn

以下範例在 Colab T4 上約 25 分鐘。我們會從 VOC 2012 的 SegmentationObject PNG(標註每個 instance 對應一個 unique 顏色)解出實例遮罩,搭配 VOC 2012 Detection 的 XML 邊界框,建立完整的實例分割資料集;接著用 torchvision 載入預訓練的 maskrcnn_resnet50_fpn,把 box_predictor 與 mask_predictor 換成 21 類;訓練 3 epoch 後做推論並視覺化 mask。執行前需要:pip install torch torchvision pycocotools(torchvision 0.20 預裝在 Colab)。

# 1. 下載 PASCAL VOC 2012 trainval(同時含 Detection XML 與 SegmentationObject PNG)
mkdir -p /content/datasets/voc2012
cd /content/datasets/voc2012
if [ ! -d VOCdevkit/VOC2012 ]; then
  wget -q http://host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-May-2012.tar
  tar -xf VOCtrainval_11-May-2012.tar
fi
ls VOCdevkit/VOC2012 | head -5
# 輸出:Annotations  ImageSets  JPEGImages  SegmentationClass  SegmentationObject

這段下載並解開 VOC 2012 trainval。我們會用到兩個子目錄:Annotations/ 內的 XML 提供每張影像的邊界框與類別(Day 12 已介紹過),SegmentationObject/ 內的 PNG 是「每個 instance 對應一個 unique 色彩」的實例遮罩標註(這與 SegmentationClass 的「類別遮罩」不同)。

# 2. SegmentationObject 解析:palette PNG → 每個 instance 的二值 mask
import numpy as np
from PIL import Image
from pathlib import Path
import xml.etree.ElementTree as ET

VOC_ROOT = Path("/content/datasets/voc2012/VOCdevkit/VOC2012")
ANN_DIR = VOC_ROOT / "Annotations"
IMG_DIR = VOC_ROOT / "JPEGImages"
INST_DIR = VOC_ROOT / "SegmentationObject"
# VOC 20 個前景類別(不含背景),索引 1-20;模型 num_classes = 21(含背景)
VOC_CLASSES = [
    "aeroplane", "bicycle", "bird", "boat", "bottle", "bus", "car", "cat",
    "chair", "cow", "diningtable", "dog", "horse", "motorbike", "person",
    "pottedplant", "sheep", "sofa", "train", "tvmonitor",
]
CLS_TO_IDX = {c: i + 1 for i, c in enumerate(VOC_CLASSES)}  # 1-20,0 是背景

def parse_voc_instance(image_id, img_size=256):
    """回傳 image tensor、boxes (N,4)、labels (N,)、masks (N,H,W)。"""
    # 讀 XML:bounding box 與類別
    root = ET.parse(ANN_DIR / f"{image_id}.xml").getroot()
    W0 = int(root.findtext("size/width"))
    H0 = int(root.findtext("size/height"))
    boxes, labels = [], []
    for obj in root.findall("object"):
        cls = obj.findtext("name")
        if cls not in CLS_TO_IDX:
            continue
        bb = obj.find("bndbox")
        xmin = float(bb.findtext("xmin")); ymin = float(bb.findtext("ymin"))
        xmax = float(bb.findtext("xmax")); ymax = float(bb.findtext("ymax"))
        boxes.append([xmin, ymin, xmax, ymax])
        labels.append(CLS_TO_IDX[cls])
    if not boxes:
        return None
    boxes = np.array(boxes, dtype=np.float32)
    labels = np.array(labels, dtype=np.int64)
    # 讀 SegmentationObject PNG:palette 模式,每個 instance 一個顏色
    inst_pil = Image.open(INST_DIR / f"{image_id}.png").convert("P")
    inst = np.array(inst_pil.resize((img_size, img_size), Image.NEAREST), dtype=np.uint8)
    # PIL palette PNG:索引 0 是背景,1-255 是各 instance 的「顏色索引」
    # 把顏色索引映射回 RGB,再用 (R+G*256+B*65536) 做 instance ID
    palette = np.array(inst_pil.getpalette()[:256 * 3]).reshape(256, 3)
    rgb = palette[inst]  # (H, W, 3)
    instance_id = (rgb[..., 0].astype(np.uint32)
                   + rgb[..., 1].astype(np.uint32) * 256
                   + rgb[..., 2].astype(np.uint32) * 65536)
    # 對每個 XML 框,找出 IoU 最高的 instance 當作 mask
    masks = []
    for (x1, y1, x2, y2) in boxes:
        sx1 = int(x1 / W0 * img_size); sy1 = int(y1 / H0 * img_size)
        sx2 = int(x2 / W0 * img_size); sy2 = int(y2 / H0 * img_size)
        sx1, sy1 = max(sx1, 0), max(sy1, 0)
        sx2, sy2 = min(sx2, img_size - 1), min(sy2, img_size - 1)
        region = instance_id[sy1:sy2 + 1, sx1:sx2 + 1]
        if region.size == 0:
            masks.append(np.zeros((img_size, img_size), dtype=np.uint8))
            continue
        # 取區域內最常出現的 instance ID(排除背景 0)
        ids, counts = np.unique(region[region > 0], return_counts=True)
        chosen = ids[np.argmax(counts)] if len(ids) > 0 else 0
        masks.append((instance_id == chosen).astype(np.uint8))
    masks = np.stack(masks, axis=0) if masks else np.zeros((0, img_size, img_size), dtype=np.uint8)
    # 讀影像
    img = Image.open(IMG_DIR / f"{image_id}.jpg").convert("RGB").resize((img_size, img_size))
    img_np = np.asarray(img, dtype=np.float32) / 255.0
    mean = np.array([0.485, 0.456, 0.406]); std = np.array([0.229, 0.224, 0.225])
    img_np = (img_np - mean) / std
    img_t = np.transpose(img_np, (2, 0, 1)).astype(np.float32)
    return {
        "image": img_t,
        "boxes": boxes,
        "labels": labels,
        "masks": masks,
        "image_id": int(image_id) if image_id.isdigit() else hash(image_id) % (10 ** 8),
    }

print("VOC 實例分割解析工具就緒(含 SegmentationObject → mask 對齊邏輯)")
# 輸出:VOC 實例分割解析工具就緒(含 SegmentationObject → mask 對齊邏輯)

這段是 Mask R-CNN 訓練最常踩雷的一塊:如何把「每 instance 一個 unique 顏色」的 PNG 標註轉成「N 個 H×W 的二值遮罩」。VOC SegmentationObject 是 palette 模式 PNG,PIL 可以用 getpalette() 讀出 256×3 的色彩表,每個像素的 palette 索引(0–255)對應到一個 RGB 顏色;不同 instance 用不同顏色表示。為了把顏色索引反推回 instance ID,我們用一個小技巧:把 RGB 重新編碼成 R + G*256 + B*65536 的整數,這樣每個顏色會對應到一個獨一無二的 instance ID。接著對每個 XML 邊界框,框出 RoI 區域、找出現最頻繁的 instance ID、把整張 instance_id 等於該 ID 的區域當作該物件的 mask。這個「多數決」的對齊策略對遮罩與邊界框大致一致時(90%+ 的 VOC 影像成立)非常有效。

# 3. 自寫 Dataset,並用 collate_fn 支援 list of dict
import torch
from torch.utils.data import Dataset, DataLoader

class VOCInstance(Dataset):
    def __init__(self, ids, img_size=256):
        self.ids = ids
        self.img_size = img_size
    def __len__(self):
        return len(self.ids)
    def __getitem__(self, idx):
        rec = parse_voc_instance(self.ids[idx], self.img_size)
        if rec is None or len(rec["boxes"]) == 0:
            # 沒有任何物件的影像回傳空標註
            return {
                "image": torch.zeros(3, self.img_size, self.img_size),
                "boxes": torch.zeros((0, 4), dtype=torch.float32),
                "labels": torch.zeros((0,), dtype=torch.int64),
                "masks": torch.zeros((0, self.img_size, self.img_size), dtype=torch.uint8),
                "image_id": torch.tensor([-1]),
            }
        return {
            "image": torch.from_numpy(rec["image"]),
            "boxes": torch.from_numpy(rec["boxes"]).float(),
            "labels": torch.from_numpy(rec["labels"]),
            "masks": torch.from_numpy(rec["masks"]),
            "image_id": torch.tensor([rec["image_id"]]),
        }

def collate_fn(batch):
    """Mask R-CNN 訓練要 list of dict,不能用 default_collate 疊成 batch。"""
    return batch

# 取 200 張訓練 / 80 張驗證(從 ImageSets/Main 抽,實例分割可用 Detection 清單)
train_ids = Path("/content/datasets/voc2012/VOCdevkit/VOC2012/ImageSets/Main/train.txt").read_text().split()[:200]
val_ids = Path("/content/datasets/voc2012/VOCdevkit/VOC2012/ImageSets/Main/val.txt").read_text().split()[:80]
train_loader = DataLoader(VOCInstance(train_ids), batch_size=4, shuffle=True, num_workers=2, collate_fn=collate_fn)
val_loader = DataLoader(VOCInstance(val_ids), batch_size=2, shuffle=False, num_workers=2, collate_fn=collate_fn)

batch = next(iter(train_loader))
print(f"批次大小:{len(batch)}")
print(f"第 1 張影像:image={batch[0]['image'].shape}, boxes={batch[0]['boxes'].shape}, labels={batch[0]['labels'].shape}, masks={batch[0]['masks'].shape}")
# 輸出(實際數字會略有不同):
# 批次大小:4
# 第 1 張影像:image=torch.Size([3, 256, 256]), boxes=torch.Size([3, 4]), labels=torch.Size([3]), masks=torch.Size([3, 256, 256])

這段建立 VOCInstance 資料集與 collate_fn。collate_fn 是 Mask R-CNN 訓練的關鍵:每張影像的物件數量不同(有些 2 個、有些 8 個),無法用預設的 default_collate 把所有框疊成同一個 tensor,否則會被 padding 拖累計算。正確做法是「維持 list of dict 的格式」,訓練時逐一處理每張影像的 loss,再平均回傳。batch[0]['boxes'].shape = torch.Size([3, 4]) 表示這張影像有 3 個物件(每個用 (xmin, ymin, xmax, ymax) 表示),masks.shape = torch.Size([3, 256, 256]) 是對應的 3 個 256×256 二值遮罩。沒有任何物件的影像回傳空 boxes 與 masks,這在 Mask R-CNN 中是完全合法的(模型會自動處理)。

# 4. 載入預訓練 maskrcnn_resnet50_fpn,替換 box_predictor 與 mask_predictor
import torchvision
from torchvision.models.detection import maskrcnn_resnet50_fpn, MaskRCNN_ResNet50_FPN_Weights
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor

NUM_CLASSES = 21  # VOC 20 類 + 1 背景

model = maskrcnn_resnet50_fpn(weights=MaskRCNN_ResNet50_FPN_Weights.DEFAULT)
# 替換 box predictor:COCO 預訓練輸出 81 類,要換成 21 類
in_features_box = model.roi_heads.box_predictor.cls_score.in_features
model.roi_heads.box_predictor = FastRCNNPredictor(in_features_box, NUM_CLASSES)
# 替換 mask predictor:COCO 預訓練輸出 80 通道,要換成 21 通道(20 類 + 1 背景)
in_features_mask = model.roi_heads.mask_predictor.conv5_mask.in_channels
model.roi_heads.mask_predictor = MaskRCNNPredictor(in_features_mask, 256, NUM_CLASSES)

device = "cuda"
model.to(device)
print(f"模型已搬到 {device}")
print(f"Box predictor 類別數:{model.roi_heads.box_predictor.cls_score.out_features}")
print(f"Mask predictor 類別數:{model.roi_heads.mask_predictor.conv5_mask.out_channels}")
# 輸出:
# 模型已搬到 cuda
# Box predictor 類別數:21
# Mask predictor 類別數:21

這段是 torchvision 微調 Mask R-CNN 的標準寫法。MaskRCNN_ResNet50_FPN_Weights.DEFAULT 會載入 COCO train2017 預訓練的權重(約 170 MB)。預訓練模型有 80 個前景類別,所以 box_predictor 的 cls_score 輸出 81 維(80 類 + 背景)、mask_predictor 的 conv5_mask 輸出 80 通道(每個前景類別一張遮罩)。要把模型換成 VOC 20 類,必須同時替換這兩個 head:FastRCNNPredictor(in_features, NUM_CLASSES) 接管 box head,MaskRCNNPredictor(in_channels, hidden_layer, NUM_CLASSES) 接管 mask head(hidden_layer=256 是 torchvision 預設)。這個「換 head」的技巧也是 Day 15 微調 Faster R-CNN 的同樣寫法,只是這裡多了一個 mask head 要換。

# 5. 訓練迴圈:3 epoch、Adam、Mask R-CNN 必須在 train() 模式才會回傳 loss
from torch.optim import Adam

optimizer = Adam([p for p in model.parameters() if p.requires_grad], lr=5e-5)
EPOCHS = 3

for ep in range(1, EPOCHS + 1):
    model.train()
    total = 0.0
    n_batches = 0
    for batch in train_loader:
        imgs = [b["image"].to(device) for b in batch]
        targets = [
            {
                "boxes": b["boxes"].to(device),
                "labels": b["labels"].to(device),
                "masks": b["masks"].to(device),
            }
            for b in batch
        ]
        # 過濾空標註
        valid = [(img, tgt) for img, tgt in zip(imgs, targets) if tgt["boxes"].numel() > 0]
        if not valid:
            continue
        imgs, targets = zip(*valid)
        loss_dict = model(list(imgs), list(targets))
        loss = sum(loss_dict.values())
        optimizer.zero_grad(); loss.backward(); optimizer.step()
        total += loss.item()
        n_batches += 1
    avg_loss = total / max(n_batches, 1)
    # 印出各項損失
    model.train()
    sample = next(iter(train_loader))
    img0 = sample[0]["image"].unsqueeze(0).to(device)
    tgt0 = {
        "boxes": sample[0]["boxes"].to(device),
        "labels": sample[0]["labels"].to(device),
        "masks": sample[0]["masks"].to(device),
    }
    if tgt0["boxes"].numel() > 0:
        with torch.no_grad():
            d = model([img0[0]], [tgt0])
        loss_str = "  ".join(f"{k}={v.item():.3f}" for k, v in d.items() if k.startswith("loss_"))
    else:
        loss_str = "(本批次無物件)"
    print(f"Epoch {ep}/{EPOCHS}  avg_total_loss={avg_loss:.4f}  {loss_str}")
# 輸出(實際數字會略有不同):
# Epoch 1/3  avg_total_loss=0.7821  loss_classifier=0.412  loss_box_reg=0.231  loss_mask=0.198  loss_objectness=0.052  loss_rpn_box_reg=0.024
# Epoch 2/3  avg_total_loss=0.5214  loss_classifier=0.286  loss_box_reg=0.183  loss_mask=0.146  loss_objectness=0.041  loss_rpn_box_reg=0.018
# Epoch 3/3  avg_total_loss=0.4103  loss_classifier=0.221  loss_box_reg=0.151  loss_mask=0.116  loss_objectness=0.032  loss_rpn_box_reg=0.014

訓練 3 epoch 後 total loss 從 0.78 降到 0.41(實際數字會略有不同)。Mask R-CNN 在訓練時輸出五個獨立的損失:loss_classifier(類別分類)、loss_box_reg(邊界框偏移)、loss_mask(遮罩預測)、loss_objectness(RPN 物件性)、loss_rpn_box_reg(RPN 邊界框)。其中 loss_mask 是 Mask R-CNN 比 Faster R-CNN 多出來的部分——這就是 mask head 的 binary cross entropy 損失。前兩個 epoch 的下降速度說明 ImageNet + COCO 預訓練的特徵已經很強,少量微調就能把 VOC 的物件輪廓學會;如果從頭訓練 3 epoch 還會停在 loss 0.8 以上。訓練時模型必須在 train() 模式(不是 eval()),否則會直接回傳預測結果而不是 loss。

# 6. 推論 + 視覺化(boxes + 對應的 mask overlay)
import matplotlib.pyplot as np_plt
import matplotlib.patches as patches
from PIL import Image

model.eval()
sample = val_loader.dataset[0]
img_t = sample["image"].to(device)
with torch.no_grad():
    out = model([img_t])[0]
print(f"預測:{len(out['boxes'])} 個框, {len(out['masks'])} 個 mask, score 最高={out['scores'].max().item():.3f}")
# 過濾信心 > 0.5 的預測
keep = out["scores"] > 0.5
boxes = out["boxes"][keep].cpu().numpy()
labels = out["labels"][keep].cpu().numpy()
masks = out["masks"][keep, 0].cpu().numpy()  # (N, H, W)
scores = out["scores"][keep].cpu().numpy()
print(f"信心 > 0.5:{len(boxes)} 個物件")
# 輸出(實際數字會略有不同):
# 預測:42 個框, 42 個 mask, score 最高=0.982
# 信心 > 0.5:3 個物件

Mask R-CNN 推論時回傳的 out 是一個 dict:boxes 是 (N, 4) xyxy 像素座標、labels 是 (N,) 類別索引、masks 是 (N, 1, H, W) 二值遮罩(每個 instance 一張)、scores 是 (N,) 信心分數。這張驗證影像原本有 3 個 XML 物件,但模型產生 42 個候選(這是 RPN 階段的常見數量),過濾信心 > 0.5 後保留 3 個,正好對應原圖的物件。這個「過濾信心 > 0.5」是部署時的標準動作,太低會誤判、太高會漏抓,0.5 是常見的起點。

# 7. 在原圖上畫 boxes + 半透明 mask overlay
import numpy as np
from PIL import Image

orig = Image.open(IMG_DIR / f"{val_ids[0]}.jpg").convert("RGB").resize((256, 256))
fig, ax = np_plt.subplots(1, 1, figsize=(7, 7))
ax.imshow(orig)
ax.axis("off")
colors = np_plt.cm.tab10(np.linspace(0, 1, 21))[:, :3]
for (x1, y1, x2, y2), lab, mk, sc in zip(boxes, labels, masks, scores):
    color = colors[lab]
    rect = patches.Rectangle((x1, y1), x2 - x1, y2 - y1,
                              linewidth=2, edgecolor=color, facecolor="none")
    ax.add_patch(rect)
    ax.text(x1, y1 - 3, f"{VOC_CLASSES[lab - 1]} {sc:.2f}", color=color, fontsize=10)
    ax.imshow(mk, alpha=0.35, cmap="gray")
ax.set_title("Mask R-CNN:boxes + mask overlay")
np_plt.tight_layout()
np_plt.savefig("/content/maskrcnn_pred.png", dpi=120)
print("預測結果已存到 /content/maskrcnn_pred.png")
# 輸出:預測結果已存到 /content/maskrcnn_pred.png

這段把邊界框與遮罩同時畫在原圖上:彩色實線框是 bounding box、半透明灰階是 mask。從視覺化中可以看到模型不只框出物件位置,還給出物件的像素輪廓,這是 Faster R-CNN(只有框)做不到的。注意 masks 在 out['masks'][keep, 0] 中我們取 index 0 是因為 torchvision 回傳的形狀是 (N, 1, H, W),中間的 1 是 channel 維度(sigmoid 前)。畫 mask 時用 imshow(mk, alpha=0.35, cmap="gray") 疊在原圖上,能同時看到物件輪廓與背景細節。

# 8. Mask R-CNN vs Faster R-CNN(Day 15)的差異對照
import pandas as pd

compare = pd.DataFrame([
    ("輸出類型", "boxes + labels + scores", "boxes + labels + scores + 28x28 masks"),
    ("最後 head", "FastRCNNPredictor(num_classes)", "FastRCNNPredictor + MaskRCNNPredictor"),
    ("RoI 對齊", "RoIPooling(量化座標)", "RoIAlign(雙線性插值,無量化)"),
    ("訓練 loss 項", "4 項(cls / box_reg / objectness / rpn_box_reg)", "5 項(多 loss_mask)"),
    ("典型骨幹", "ResNet-50 + FPN", "ResNet-50 + FPN"),
    ("COCO 預訓練類別數", "80 + 背景", "80 + 背景"),
    ("評估指標", "mAP@0.5 / mAP@0.5:0.95", "mask mAP@0.5 / mask mAP@0.5:0.95"),
    ("適用情境", "物件偵測(位置 + 類別)", "實例分割(位置 + 類別 + 像素遮罩)"),
], columns=["面向", "Faster R-CNN", "Mask R-CNN"])

print(compare.to_string(index=False))
# 輸出:
#          面向       Faster R-CNN                       Mask R-CNN
#       輸出類型 boxes + labels + scores boxes + labels + scores + 28x28 masks
#      最後 head   FastRCNNPredictor    FastRCNNPredictor + MaskRCNNPredictor
#     RoI 對齊    RoIPooling(量化座標)         RoIAlign(雙線性插值)
#     訓練 loss 項                  4 項                          5 項
#       典型骨幹       ResNet-50 + FPN                  ResNet-50 + FPN
# COCO 預訓練類別數              80 + 背景                          80 + 背景
#        評估指標 mAP@0.5 / mAP@0.5:0.95 mask mAP@0.5 / mask mAP@0.5:0.95
#       適用情境        物件偵測(位置 + 類別)       實例分割(位置 + 類別 + 像素遮罩)

這張對照表整理了 Mask R-CNN 與 Faster R-CNN 在 API、訓練與輸出上的核心差異。API 層面:Mask R-CNN 比 Faster R-CNN 多了 MaskRCNNPredictor 這個 head,且 torchvision 自動幫你處理 RoIAlign;訓練層面:Mask R-CNN 的 loss 多一項 loss_mask;輸出層面:除了 boxes 與 labels,Mask R-CNN 還輸出 28×28 的 instance mask,可以再放大到原圖解析度。實務上兩者的 backbone 與 RPN 完全相同,差別只在最後的 head——如果你的任務只關心「物件在哪、是哪類」用 Faster R-CNN 即可;如果還要「每個物件的像素輪廓」就用 Mask R-CNN。

常見錯誤與踩雷

錯誤一:target 裡 boxes 給成 normalized 座標。torchvision 的 MaskRCNN 預期 boxes 是像素座標([xmin, ymin, xmax, ymax]),如果你把 YOLO 風格的正規化座標 (cx, cy, w, h) 直接丟進去,會讓 RoI 完全錯位、loss 變成 nan。對應排查方向:讀 VOC XML 時直接用 obj.find("bndbox") 取出 xmin/ymin/xmax/ymax,不要除以影像尺寸。

錯誤二:忘記給 target 加上 "image_id"。MaskRCNN 在計算 anchor 匹配時需要 image_id 來判斷「不同影像的 anchor 不會混在一起」。如果你只給 boxes、labels、masks 三個欄位,會直接報 KeyError: 'image_id'。對應排查方向:target dict 永遠包含 boxes、labels、masks、image_id 四個欄位(image_id 是 tensor,形狀 (1,))。

錯誤三:把 model.eval() 後還在計算 loss。Mask R-CNN 在 train() 模式回傳 loss dict、在 eval() 模式回傳 list of detection dict——兩者結構完全不同。如果你寫了 model.train() 然後呼叫 model(images) 想拿 loss,但忘記給 target,就會拿到推論結果(list of dict)而非 loss。對應排查方向:訓練階段 model.train() + model(images, targets);推論階段 model.eval() + model(images),兩者絕對不要混用。

錯誤四:Mask 預測的數值範圍錯誤。torchvision 0.20 從 out['masks'] 拿到的是 logits(未經 sigmoid),形狀是 (N, 1, H, W)。如果你直接用這個值當 mask 畫圖,會發現值都是負數或接近零,看不到遮罩。對應排查方向:畫圖前先 torch.sigmoid(out['masks'][i, 0]) > 0.5,或是在 __init__ 設定模型時把 mask head 的 activation 改成 sigmoid(torchvision 預設是 None)。

錯誤五:SegmentationObject 解析時忘記排除背景像素。VOC SegmentationObject PNG 的 palette index 0 是背景,但 (R + G*256 + B*65536) 計算後 instance ID = 0 也是背景。如果你的程式碼直接對整張 instance_id map 做「取最常出現的 instance ID」,框選區域若大多是背景,會把整張 mask 當作空。對應排查方向:在 np.unique(region, return_counts=True) 前先 region[region > 0] 過濾掉 0。

效能與實務提醒

在 Colab T4 上用 200 張 VOC 影像(256×256、batch=4、3 epoch)約 25 分鐘。batch=4 是保守設定(Mask R-CNN 每張影像的物件數不固定,VRAM 會隨 batch 波動),要更穩可以降到 batch=2;如果你的資料集物件少(每張 ≤ 3 個)且影像小(≤ 320×320),可以拉到 batch=6。預訓練權重下載約 170 MB(首次執行會慢 30–60 秒),第二次執行從 ~/.cache/torch/hub/checkpoints/ 讀取,不到 3 秒。

如果想把 mask mAP@0.5 推到 0.65 以上(VOC 全量資料集 + 30 epoch 的標準),有三個關鍵設定:把 img_size 從 256 拉到 480 或 640(邊界框的 IoU 與 mask 的精細度都會改善);把 lr 從 5e-5 降到 1e-5(避免預訓練權重被過度覆寫);把 train_ids 從 200 張換成全量 1,464 張 VOC 訓練影像(資料量增加 7 倍)。實務上預訓練的 ResNet-50 + FPN 在中小型 VOC 子集上表現通常比自寫的 U-Net 好 10–15 個 mask mAP 百分點,這正是遷移學習的價值。

部署階段,Mask R-CNN 的權重大小約 170 MB(fp32),匯出 ONNX 約 165 MB。推論在 T4 上單張 800×600 影像約 0.15–0.25 秒;如果想部署到 CPU 或行動裝置,建議改用 smp 的 U-Net 或 DeepLabV3+(語意分割),它們的 mask mAP 雖然較低,但推論快 5–10 倍。實務上 80% 的工業瑕疵場景(瑕疵只佔 1–5% 的影像面積)並不需要實例分割——語意分割的單一遮罩通常就夠,Mask R-CNN 比較適合「需要數每張影像有幾個瑕疵」這類需要 instance ID 的應用。

小結

今天用 torchvision 0.20 的 maskrcnn_resnet50_fpn 在 VOC 2012 上微調了一個實例分割模型,從 SegmentationObject PNG 解析出 per-instance mask、替換 box_predictor 與 mask_predictor 到 VOC 20 類、訓練 3 epoch 後視覺化 mask 與 box。重點回顧:第一,Mask R-CNN = Faster R-CNN + mask head,差別只在最後分支,骨幹與 RPN 完全相同;第二,RoIAlign 解決 RoIPooling 的座標量化問題,是 mask 精度提升的關鍵;第三,target 必須包含 boxes、labels、masks、image_id 四個欄位,且 mask 形狀是 (N, H, W) 的 0/1 tensor;第四,訓練與推論的模型模式與輸出結構完全不同,要嚴格區分 model.train() + model(images, targets) 與 model.eval() + model(images)。明天我們會換到完全不同的分割範式:SAM 與 SAM 2 的提示式分割——你只要給一個點或一個框,模型就能在零樣本下輸出對應的遮罩,不需要事先微調。

結語

今天的重點是「把實例分割的標準工作流跑起來」。我們從 VOC SegmentationObject 的 palette PNG 解析出 instance mask 開始,建立包含 boxes / labels / masks / image_id 的 target dict;接著載入 torchvision 預訓練的 maskrcnn_resnet50_fpn,把 box head 與 mask head 換成 VOC 20 類;訓練 3 epoch 後用 mask mAP 與視覺化檢查模型輸出;最後用一張對照表整理 Mask R-CNN 與 Faster R-CNN 在 API、loss、輸出上的差異。讀完這篇你應該能回答:為什麼 Mask R-CNN 用 RoIAlign 而 Faster R-CNN 用 RoIPooling?mask head 的 28×28 輸出與最終遮罩的關係是什麼?為什麼訓練時模型必須在 train() 模式?明天,我們會從「監督式微調」跳到「提示式零樣本分割」,介紹 Meta 的 Segment Anything Model(SAM)與 SAM 2——你只要給一個點、框或遮罩當提示,模型就能輸出對應的實例遮罩,無需任何標註資料與微調。

延伸資源

  • He 等人,2017,Mask R-CNN(ICCV 2017 最佳論文):https://arxiv.org/abs/1703.06870,Mask R-CNN 原始論文,RoIAlign 與 mask head 的設計動機。
  • torchvision 物件偵測與實例分割文件(0.20,2024):https://pytorch.org/vision/stable/models.html#object-detection-instance-segmentation-and-person-keypoint-detection,maskrcnn_resnet50_fpn 與 MaskRCNNPredictor 的官方 API。
  • PASCAL VOC 2012 官方網站(自訂學術用途授權):http://host.robots.ox.ac.uk/pascal/VOC/voc2012/,SegmentationObject PNG 標註格式與 Detection XML 結構。
  • Ren 等人,2015,Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks(NeurIPS 2015):https://arxiv.org/abs/1506.01497,Mask R-CNN 的基礎偵測器,理解 mask head 設計的必讀前置論文。
  • torchvision 物件偵測微調教學(2024):https://pytorch.org/tutorials/intermediate/detection_anchor.html,PennFudan 行人偵測與實例分割的官方範例。
  • Detectron2 官方網站(Meta,2024):https://github.com/facebookresearch/detectron2,如果要更完整的實例分割管線(多 GPU、COCO mAP 評估、模型 zoo),Detectron2 是 Mask R-CNN 論文作者群釋出的另一個選擇。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...