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 論文作者群釋出的另一個選擇。
留言
張貼留言