Bỏ qua để đến nội dung

Instance và Universal Segmentation: Mask R-CNN, Mask2Former, SAM

Sau bài này, bạn sẽ:

  • Phân biệt được ba bài toán: semantic segmentation, instance segmentation (phân vùng từng đối tượng) và panoptic segmentation (phân vùng toàn cảnh). Tính được độ đo PQ (panoptic quality) và tách nó thành hai phần SQ, RQ.
  • Giải thích được Mask R-CNN: phát hiện hộp trước, rồi vẽ mask trong từng hộp; và vì sao RoIAlign thay RoIPool.
  • Hiểu hướng phân loại mask (mask classification) bằng truy vấn: DETR, MaskFormer, rồi Mask2Former với masked attention, một kiến trúc cho cả ba bài toán.
  • Dùng được SAM với điểm gợi ý (point prompt), và giải thích vì sao SAM trả về ba mask cho một điểm.

Bạn có một ảnh hai người đứng vai kề vai. Một mô hình semantic segmentation tốt tô đúng mọi pixel người bằng cùng một màu. IoU của lớp “người” gần bằng 1.

Giờ hỏi: trong ảnh có mấy người? Mô hình không trả lời được. Nó chỉ biết “pixel này là người”, không biết “pixel này thuộc người nào”. Với xe tự lái đếm người qua đường, robot nhặt từng quả táo, hay phần mềm đếm tế bào, câu hỏi “người nào, quả nào” mới là thứ cần.

Cách đơn giản nhất: lấy vùng “người”, tách thành các mảng liền nhau, mỗi mảng là một người. Cách này đúng khi các vật nằm rời nhau. Nhưng hai người chạm vai thành một mảng liền, nên bị đếm là một. Ô NumPy đầu tiên của bài cho thấy đúng chỗ hỏng này.

Hướng thứ nhất: tìm vật trước, rồi tô từng vật. Một bộ phát hiện vật thể (object detector) vẽ một hộp bao (bounding box) quanh mỗi người, rồi trong mỗi hộp ta tô pixel nào là người đó. Hai người đứng sát vẫn có hai hộp, nên vẫn ra hai mask. Đây là Mask R-CNN.

Hướng thứ hai: cho mô hình một số “khe” cố định, ví dụ 100 khe, và yêu cầu mỗi khe trả về một mask kèm một nhãn lớp (hoặc “không có gì”). Mỗi khe tự “nhận” một vật. Cách này không cần hộp, và cùng một mô hình làm được cả semantic lẫn instance. Đây là MaskFormer và Mask2Former.

Hướng thứ ba khác hẳn: người dùng chỉ vào vật cần tách (bấm một điểm, vẽ một hộp), mô hình trả về mask của vật đó, kể cả loại vật chưa từng có trong dữ liệu huấn luyện. Đây là SAM.

Bài toán của bài này là: làm sao tách từng vật riêng lẻ, kể cả khi chúng chạm nhau, đo chất lượng việc tách đó thế nào, và ba hướng trên giải nó ra sao?

Kirillov và cộng sự (2019) chia lớp trong ảnh thành hai loại:

  • Thing (vật đếm được): người, xe, quả táo. Mỗi cái có hình dạng riêng, đếm được.
  • Stuff (vùng không đếm được): bầu trời, mặt đường, cỏ. Không ai hỏi “có mấy bầu trời”.

Từ đó có ba bài toán trên cùng một ảnh H×WH \times W:

Bài toánĐầu raThingStuff
Semantic segmentationMỗi pixel một lớpGộp mọi vật cùng lớpCó
Instance segmentationMột tập {(Mi,ci,si)}\{(\mathbf{M}_i, c_i, s_i)\}: mask, lớp, điểm tin cậyMỗi vật một maskKhông
Panoptic segmentationMỗi pixel một cặp (lớp, mã đối tượng)Mỗi vật một mã riêngMột vùng mỗi lớp

Ở đây Mi∈{0,1}H×W\mathbf{M}_i \in \{0, 1\}^{H \times W} là mask nhị phân của vật thứ ii, cic_i là lớp, sis_i là điểm tin cậy. Mask của instance segmentation được phép chồng lên nhau (mô hình có thể trả về nhiều mask cho cùng một vùng, xếp theo điểm). Panoptic thì không: mỗi pixel thuộc đúng một đoạn (segment).

Với semantic segmentation, bài trước dùng mIoU. Nhưng mIoU không thấy lỗi gộp hai người: tô đúng mọi pixel người thì IoU lớp người vẫn bằng 1. Panoptic cần độ đo nhìn vào từng đoạn. Kirillov và cộng sự (2019) đề xuất PQ (panoptic quality), tính cho từng lớp rồi lấy trung bình.

Bước 1: ghép đoạn. Một đoạn dự đoán pp ghép với một đoạn thật gg khi IoU(p,g)>0.5\text{IoU}(p, g) > 0.5. Vì sao 0.5? Các đoạn thật không chồng nhau, các đoạn dự đoán cũng vậy. Nếu pp chiếm hơn nửa phần hợp với gg, thì không còn chỗ cho một g′g' khác cũng chiếm hơn nửa phần hợp với pp. Bài báo chứng minh điều này: ngưỡng 0.5 cho ghép duy nhất, không cần thuật toán ghép đôi nào.

Bước 2: chia ba nhóm. Cặp ghép được là TP (true positive). Đoạn dự đoán không ghép với ai là FP. Đoạn thật không ai ghép là FN.

Bước 3: công thức.

PQ=∑(p,g)∈TPIoU(p,g)∣TP∣+12∣FP∣+12∣FN∣\text{PQ} = \frac{\sum_{(p, g) \in \text{TP}} \text{IoU}(p, g)}{|\text{TP}| + \frac{1}{2}|\text{FP}| + \frac{1}{2}|\text{FN}|}

Tử số cộng IoU của các cặp ghép được: ghép càng khít, tử số càng lớn. Mẫu số đếm số cặp, cộng thêm nửa điểm phạt cho mỗi đoạn thừa và mỗi đoạn sót.

Bước 4: tách làm hai. Nhân rồi chia cho ∣TP∣|\text{TP}|, ta được hai thừa số dễ đọc:

PQ=∑TPIoU(p,g)∣TP∣⏟SQ×∣TP∣∣TP∣+12∣FP∣+12∣FN∣⏟RQ\text{PQ} = \underbrace{\frac{\sum_{\text{TP}} \text{IoU}(p, g)}{|\text{TP}|}}_{\text{SQ}} \times \underbrace{\frac{|\text{TP}|}{|\text{TP}| + \frac{1}{2}|\text{FP}| + \frac{1}{2}|\text{FN}|}}_{\text{RQ}}

Thừa số đầu, SQ (segmentation quality), là IoU trung bình của các cặp đã ghép: mask khít tới đâu. Thừa số sau, RQ (recognition quality), chính là điểm F1 quen thuộc của bài toán phát hiện: tìm đúng bao nhiêu vật.

Áp vào ví dụ của ô NumPy: ba đồng xu A, B, C, trong đó B và C chạm nhau. Cách “mảng liền nhau” cho hai đoạn: A khớp hoàn toàn (IoU 1), còn mảng B+C có IoU 0.58 với C và 0.42 với B. Vậy TP gồm (A, A) và (B+C, C), không có FP, còn B là FN:

PQ=1+0.582+0+12⋅1≈0.633,SQ≈0.79,RQ=0.8\text{PQ} = \frac{1 + 0.58}{2 + 0 + \frac{1}{2} \cdot 1} \approx 0.633, \qquad \text{SQ} \approx 0.79, \quad \text{RQ} = 0.8

Trong khi đó, mask semantic của lớp “xu” vẫn đúng từng pixel.

Bài về phát hiện vật thể chưa có, nên đây là ba ý bài này cần. Một bộ phát hiện trả về các hộp [x1,y1,x2,y2][x_1, y_1, x_2, y_2], mỗi hộp có lớp và điểm tin cậy. Độ trùng giữa hai hộp đo bằng IoU, như với mask, chỉ là tính trên hình chữ nhật: phần giao rộng max⁡(0,min⁡(x2,x2′)−max⁡(x1,x1′))\max(0, \min(x_2, x_2') - \max(x_1, x_1')) và cao tính tương tự.

Bộ phát hiện thường trả về nhiều hộp chồng nhau cho cùng một vật. NMS (non-maximum suppression, triệt tiêu điểm không cực đại) dọn chúng: sắp hộp theo điểm giảm dần, giữ hộp cao nhất, bỏ mọi hộp còn lại có IoU với nó lớn hơn một ngưỡng, rồi lặp lại. Ngưỡng là con dao hai lưỡi: thấp quá thì xóa nhầm người thật đứng sát nhau, cao quá thì sót hộp trùng. Ô NumPy thứ hai cho thấy điều này.

Mask R-CNN (He và cộng sự, 2017) xây trên Faster R-CNN (Ren và cộng sự, 2015), một bộ phát hiện hai giai đoạn:

  1. Backbone (mạng xương sống, thường là ResNet kèm FPN) biến ảnh thành bản đồ đặc trưng.
  2. RPN (region proposal network, mạng đề xuất vùng) đề xuất vài trăm tới vài nghìn hộp có thể chứa vật.
  3. Với mỗi hộp đề xuất, gọi là RoI (region of interest, vùng quan tâm), lấy ra một bản đồ đặc trưng nhỏ cỡ cố định, ví dụ 7×77 \times 7 hoặc 14×1414 \times 14.
  4. Các đầu ra trên đặc trưng đó: đầu phân loại (lớp nào), đầu hồi quy hộp (chỉnh hộp cho khít), và phần Mask R-CNN thêm vào: đầu mask.

Đầu mask là một mạng tích chập nhỏ, cho ra KK mask cỡ m×mm \times m (bài báo dùng 28×2828 \times 28), mỗi lớp một mask. Mỗi pixel đi qua sigmoid và học bằng cross-entropy nhị phân. Chỉ mask của lớp đúng được tính vào mất mát; lúc suy luận, mask được chọn theo lớp mà đầu phân loại đoán. Tác giả thấy tách việc “là lớp gì” khỏi việc “pixel nào thuộc vật” quan trọng: dùng softmax cạnh tranh giữa các lớp ở từng pixel cho kết quả kém hơn rõ. Mất mát của mỗi RoI là tổng ba phần:

L=Lcls+Lbox+Lmask\mathcal{L} = \mathcal{L}_{\text{cls}} + \mathcal{L}_{\text{box}} + \mathcal{L}_{\text{mask}}

Mask 28×2828 \times 28 sau đó được phóng về đúng cỡ hộp trên ảnh. Vì mỗi hộp có mask riêng, hai người đứng sát vẫn ra hai mask. Đổi lại, chất lượng phụ thuộc vào hộp: hộp sót thì vật mất, hộp bị NMS xóa nhầm thì vật cũng mất.

Bước 3 nghe đơn giản nhưng có bẫy. Backbone thu nhỏ ảnh, ví dụ stride 16: một ô đặc trưng ứng với 16×1616 \times 16 pixel. Hộp có tọa độ số thực trên ảnh, nên trên bản đồ đặc trưng tọa độ thành x/16x / 16.

RoIPool (cách cũ) làm tròn hai lần. Lần một: tọa độ hộp. Cạnh trái ở pixel 33 thành 33/16≈2.0633 / 16 \approx 2.06, làm tròn thành ô 2, tức pixel 32 trên ảnh. Lần hai: khi chia hộp thành lưới 7×77 \times 7 ô, ranh giới mỗi ô lại bị làm tròn. Mỗi lần làm tròn có thể lệch tới nửa ô, tức tới 8 pixel trên ảnh. Phân loại chịu được lệch nhỏ như vậy: con mèo dịch vài pixel vẫn là con mèo. Mask thì không: lệch 8 pixel là đường viền lệch hẳn.

RoIAlign bỏ mọi phép làm tròn. Giữ nguyên 2.06, chia hộp thành 7×77 \times 7 ô bằng số thực. Trong mỗi ô, lấy giá trị tại 4 điểm đặt đều, mỗi điểm tính bằng nội suy song tuyến tính (bilinear interpolation) từ 4 ô đặc trưng gần nhất, rồi gộp lại bằng max hoặc trung bình. Thay đổi nhỏ, hiệu quả lớn: trong bảng thí nghiệm của bài báo (ResNet-50-C4), AP của mask tăng từ 26.9 (RoIPool) lên 30.3 (RoIAlign).

DETR (Carion và cộng sự, 2020) làm phát hiện vật thể theo kiểu khác. Mô hình có NN truy vấn vật thể (object query), bài báo dùng N=100N = 100: các vectơ học được, đi qua một decoder Transformer, nhìn sang đặc trưng ảnh bằng cross-attention. Mỗi truy vấn trả ra một hộp và một lớp, hoặc lớp đặc biệt ∅\varnothing (“không có vật”). Khi huấn luyện, mỗi vật thật được ghép đôi (bipartite matching) với đúng một truy vấn, theo cách ghép có tổng chi phí nhỏ nhất. Truy vấn thứ hai cũng đoán cùng vật đó sẽ không được ghép và bị phạt, nên mô hình tự học không đoán trùng. Kết quả: không cần NMS, không cần hộp neo.

MaskFormer (Cheng và cộng sự, 2021) đổi hộp thành mask. Cùng NN truy vấn, mỗi truy vấn ii cho ra một phân phối trên K+1K + 1 lớp (thêm ∅\varnothing) và một mask embedding ei∈RD\mathbf{e}_i \in \mathbb{R}^{D}. Song song, một bộ giải mã pixel (pixel decoder) cho mỗi pixel (h,w)(h, w) một vectơ ph,w∈RD\mathbf{p}_{h,w} \in \mathbb{R}^{D}. Mask của truy vấn ii là:

Mi[h,w]=σ(ei⊤ph,w)\mathbf{M}_i[h, w] = \sigma\big(\mathbf{e}_i^\top \mathbf{p}_{h,w}\big)

Tích vô hướng lớn nghĩa là pixel giống “vật” mà truy vấn đang giữ; sigmoid σ\sigma đưa nó về khoảng (0,1)(0, 1). Đây là ý tưởng cốt lõi: bài toán nào cũng là “dự đoán một tập mask, mỗi mask một nhãn”. Semantic segmentation: mỗi lớp một mask. Instance: mỗi vật một mask. Panoptic: cả hai. Chỉ cách ghép kết quả lúc suy luận là khác. Bài báo tên là “Per-Pixel Classification is Not All You Need”, đúng ý: phân loại từng pixel không phải cách duy nhất, kể cả cho semantic segmentation.

MaskFormer học chậm và yếu với vật nhỏ. Mask2Former (Cheng và cộng sự, 2022) sửa bằng một ý: ở mỗi lớp decoder, truy vấn chỉ được nhìn vào vùng mask mà chính nó vừa đoán ở lớp trước.

Gọi Xl∈RN×D\mathbf{X}_l \in \mathbb{R}^{N \times D} là NN truy vấn sau lớp thứ ll. Cross-attention thường cho truy vấn nhìn mọi pixel. Masked attention thêm một ma trận Ml−1\boldsymbol{\mathcal{M}}_{l-1} vào trước softmax (bài báo viết công thức không có hệ số 1/d1/\sqrt{d}, ở đây giữ đúng như vậy):

Xl=softmax(Ml−1+QlKl⊤) Vl+Xl−1\mathbf{X}_l = \mathrm{softmax}\big(\boldsymbol{\mathcal{M}}_{l-1} + \mathbf{Q}_l \mathbf{K}_l^\top\big)\,\mathbf{V}_l + \mathbf{X}_{l-1}

Ql\mathbf{Q}_l lấy từ các truy vấn, Kl\mathbf{K}_l và Vl\mathbf{V}_l lấy từ đặc trưng ảnh. Phần tử của Ml−1\boldsymbol{\mathcal{M}}_{l-1} ở hàng ii, vị trí (x,y)(x, y) bằng 0 nếu mask lớp trước của truy vấn ii (cắt ngưỡng 0.5) chứa pixel đó, và bằng −∞-\infty nếu không. Vì sao −∞-\infty? Vì e−∞=0e^{-\infty} = 0: sau softmax, trọng số attention ở các pixel ngoài mask bằng đúng 0. Truy vấn đang giữ “con mèo” chỉ gom đặc trưng từ vùng con mèo, không bị phân tán khắp ảnh. + Xl−1+\,\mathbf{X}_{l-1} là kết nối tắt quen thuộc.

Thêm vài cải tiến (đặc trưng nhiều độ phân giải luân phiên giữa các lớp decoder, tính mất mát mask trên một số điểm lấy mẫu thay vì cả mask để tiết kiệm bộ nhớ), Mask2Former đạt 57.8 PQ trên COCO panoptic, 50.1 AP trên COCO instance và 57.7 mIoU trên ADE20K semantic (bài báo, 2022, backbone Swin-L), cùng một kiến trúc. Với backbone ResNet-50, nó đạt 51.9 PQ sau 50 epoch, trong khi MaskFormer cần 300 epoch để đạt 46.5.

“Universal” ở đây là một kiến trúc, không phải một bộ trọng số: chính bài báo ghi rằng mô hình vẫn phải huấn luyện riêng cho từng bài toán, và bản chỉ học panoptic hơi kém bản học riêng instance hay semantic.

Mask R-CNN và Mask2Former chỉ biết các lớp có trong dữ liệu huấn luyện (COCO có 80 lớp thing). SAM (Kirillov và cộng sự, 2023) đặt bài toán khác: phân vùng theo gợi ý (promptable segmentation). Đầu vào là ảnh cộng một prompt: một hoặc vài điểm, một hộp, hay một mask thô. Đầu ra là mask hợp lệ cho thứ được chỉ vào, bất kể đó là gì. SAM không trả về nhãn lớp.

SAM có ba phần, thiết kế xoay quanh tốc độ:

  • Bộ mã hóa ảnh (image encoder): một ViT lớn (bản lớn nhất là ViT-H), pre-train bằng MAE, nhận ảnh 1024×10241024 \times 1024 và cho ra embedding 64×6464 \times 64 với 256 kênh. Phần này nặng, nhưng chỉ chạy một lần cho mỗi ảnh.
  • Bộ mã hóa prompt (prompt encoder): đổi điểm và hộp thành vectơ (mã hóa vị trí cộng embedding học được).
  • Bộ giải mã mask (mask decoder): vài khối Transformer nhẹ, attention hai chiều giữa token prompt và embedding ảnh. Theo bài báo, khi đã có embedding ảnh, bộ mã hóa prompt và bộ giải mã chạy khoảng 50 ms trên CPU trong trình duyệt.

Một điểm gợi ý thường mơ hồ: bấm vào cái áo thì ý bạn là cái áo, hay cả người? Nếu chỉ có một đầu ra, mô hình sẽ đoán một mask lưng chừng, trung bình của các đáp án. SAM trả về 3 mask cho một điểm (bài báo: các mask lồng nhau thường sâu tối đa ba tầng, nguyên vật, phần, phần nhỏ), kèm một điểm IoU dự đoán (iou_scores) cho mỗi mask, để xếp hạng. Khi huấn luyện, chỉ mask khớp nhất với đáp án được lan truyền ngược.

Dữ liệu là phần lớn của câu chuyện. Mask theo pixel đắt, nên nhóm tác giả dựng một động cơ dữ liệu (data engine) ba giai đoạn: người gán nhãn dùng SAM để gán nhanh, rồi SAM tự đề xuất mask, cuối cùng SAM tự gán toàn bộ. Kết quả là SA-1B: 11 triệu ảnh, 1.1 tỉ mask.

SAM 2 (Ravi và cộng sự, 2024) mở rộng sang video: thêm bộ nhớ (memory bank) lưu đặc trưng và dự đoán của các khung hình trước, và một khối memory attention cho khung hiện tại nhìn vào bộ nhớ đó. Bạn bấm vào vật ở một khung, mô hình theo vật đó qua cả video. Bài báo xây bộ SA-V (35.5 triệu mask trên 50.9 nghìn video) và báo cáo trên ảnh SAM 2 chính xác hơn và nhanh hơn 6 lần so với SAM.

  • Mask R-CNN phụ thuộc hộp và NMS. Cảnh đông, vật chồng nhiều lên nhau dễ bị xóa nhầm (ô NMS của bài, câu hỏi thứ tư).
  • Mask2Former là một kiến trúc, không phải một mô hình cho mọi thứ. Vẫn huấn luyện riêng từng bài toán, và chỉ biết các lớp đã học.
  • SAM không biết vật là gì. Muốn có nhãn lớp, phải ghép với một mô hình khác, ví dụ một bộ phát hiện theo câu chữ như GLIP ở bài CLIP, BLIP, GLIP, hoặc một bộ phân loại.
  • Bài không đi sâu vào phát hiện vật thể (các họ R-CNN, YOLO, hộp neo); chỉ nhắc phần Mask R-CNN cần.

Hình dưới đặt ba đáp án của cùng một cảnh cạnh nhau. Hãy nhìn hai người đứng sát: ở ô semantic họ cùng một màu, ở ô instance và panoptic họ hai màu khác nhau; còn bầu trời và mặt đường thì biến mất ở ô instance.

Bảng dưới so ba họ mô hình của bài theo cùng các câu hỏi. Số liệu lấy từ bài báo của từng mô hình.

Tiêu chíMask R-CNN (2017)Mask2Former (2022)SAM (2023)
Ý chínhTìm hộp, rồi tô mask trong mỗi hộpNN truy vấn, mỗi truy vấn một mask và một lớpTách thứ được gợi ý (điểm, hộp, mask)
Bài toánInstance (và phát hiện)Semantic, instance, panoptic, cùng kiến trúcMask không nhãn, theo gợi ý
Có nhãn lớp khôngCó, các lớp đã họcCó, các lớp đã họcKhông
Bỏ trùng lặp bằngNMS trên hộpGhép đôi khi huấn luyện, không NMSNgười dùng chọn vật; 3 mask kèm điểm IoU dự đoán
Điểm mạnhĐơn giản, đầu mask rẻ, mở rộng sang điểm khớp ngườiMột thiết kế cho cả ba bài toán, masked attention hội tụ nhanhLàm được với vật chưa từng thấy; gán nhãn nhanh
Cái giáPhụ thuộc chất lượng hộp; mask chỉ 28×2828 \times 28Vẫn huấn luyện riêng từng bài toán; tốn bộ nhớBộ mã hóa ảnh nặng; không biết vật là gì

Code trong bài chạy thử lần cuối tháng 11/2026: hai ô NumPy với Python 3.14, NumPy 2.4 và SciPy 1.18 (bản có trong Pyodide), ô SAM với Python 3.14, PyTorch 2.15 và transformers 5.22 trên CPU. Hai ô NumPy chạy ngay trên trang, ô SAM chạy trên máy bạn. Các ô độc lập, không dùng biến của nhau. Ô đầu tiên lần đầu chạy lâu hơn, vì trình duyệt phải tải thêm SciPy.

Ô dưới vẽ ba đồng xu cùng lớp trên lưới 20×3420 \times 34: xu A nằm riêng, xu B và xu C chạm nhau. Mask semantic là hợp của ba mask thật. Cách 1 tách bằng scipy.ndimage.label (mỗi mảng liền nhau theo 4 hướng là một vật). Cách 2 co mask một pixel (binary_erosion) để cắt chỗ chạm, đánh số các mảng còn lại làm “hạt giống”, rồi trả mỗi pixel của mask về hạt giống gần nhất. Mỗi cách in ma trận IoU giữa đoạn dự đoán (hàng) và vật thật (cột), rồi tính PQ như mục Lý thuyết.

Tách vật từ mask semantic, ghép theo IoU, tính PQ

import numpy as np
from scipy import ndimage
# Ảnh 20 x 32 có ba đồng xu cùng một lớp; xu B và xu C chạm nhau
yy, xx = np.indices((20, 34))
def disk(cy, cx, r):
return (yy - cy) ** 2 + (xx - cx) ** 2 <= r ** 2
gt = [disk(6, 5, 4), disk(11, 15, 5), disk(11, 27, 6)] # 3 mask thật
semantic = gt[0] | gt[1] | gt[2] # mask semantic: chỉ biết "là xu"
def iou(a, b):
return (a & b).sum() / (a | b).sum()
def pq(pred):
# Ghép dự đoán với vật thật khi IoU > 0.5, rồi tính PQ
M = np.array([[iou(p, g) for g in gt] for p in pred])
tp = [M[i, j] for i in range(len(pred)) for j in range(3)
if M[i, j] > 0.5]
fp, fn = len(pred) - len(tp), 3 - len(tp)
print("IoU (pred x true):")
print(np.round(M, 2))
print(f"TP={len(tp)} FP={fp} FN={fn} "
f"PQ={sum(tp) / (len(tp) + 0.5 * fp + 0.5 * fn):.3f}")
# Cách 1: mỗi vùng liền nhau là một vật
labels, n = ndimage.label(semantic)
print(f"components: {n}")
pq([labels == k for k in range(1, n + 1)])
# Cách 2: co mask để tách chỗ chạm, đánh số, rồi trả mỗi pixel
# về hạt giống gần nhất (một dạng watershed rất thô)
seeds, n2 = ndimage.label(ndimage.binary_erosion(semantic, iterations=1))
_, (iy, ix) = ndimage.distance_transform_edt(seeds == 0,
return_indices=True)
labels2 = seeds[iy, ix] * semantic
print(f"components after erosion: {n2}")
pq([labels2 == k for k in range(1, n2 + 1)])

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • Cách 1, components: 2: ba đồng xu nhưng chỉ hai mảng. Hàng thứ hai của ma trận là mảng B+C: IoU 0.42 với B, 0.58 với C. Chỉ cặp với C vượt 0.5, nên TP=2 FP=0 FN=1 và PQ=0.633, đúng số đã tính tay.
  • Cách 2, components after erosion: 3: co một pixel cắt đứt chỗ chạm, ba hạt giống mọc lại thành ba mask khớp hoàn toàn, PQ=1.000. Thứ tự hàng đổi (C trước B) vì label đánh số theo thứ tự quét từ trên xuống, và C to hơn nên chạm hàng trên sớm hơn.

Mẹo co mask là phiên bản rất thô của thuật toán watershed. Nó chạy được ở đây vì chỗ chạm chỉ mỏng một pixel. Hai vật chồng lên nhau một mảng lớn, hay hai vật dính nhau suốt một cạnh dài, thì co bao nhiêu cũng không tách đúng. Vì thế các mô hình instance học trực tiếp “vật nào là vật nào”, thay vì đoán lại từ mask semantic.

Ô dưới có 4 hộp: hộp 0 và 1 cùng khoanh người A, hộp 2 khoanh người B đứng sát A, hộp 3 khoanh người C ở xa. Code tính IoU giữa mọi cặp hộp, rồi chạy NMS với ba ngưỡng.

NMS bằng NumPy với ba ngưỡng

import numpy as np
# Hộp [x1, y1, x2, y2] và điểm tin cậy của bộ phát hiện
# 0, 1: hai hộp cho cùng người A; 2: người B đứng sát A; 3: người C
boxes = np.array([[10, 10, 50, 90], [14, 12, 54, 92],
[22, 10, 62, 90], [100, 20, 140, 100]], dtype=float)
scores = np.array([0.95, 0.80, 0.90, 0.85])
def box_iou(a, b):
# Phần giao: cạnh trái lớn nhất, cạnh phải nhỏ nhất
w = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
h = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
inter = w * h
area = lambda r: (r[2] - r[0]) * (r[3] - r[1])
return inter / (area(a) + area(b) - inter)
def nms(thr):
order = list(np.argsort(-scores)) # điểm cao trước
keep = []
while order:
i = order.pop(0)
keep.append(int(i))
# bỏ mọi hộp còn lại chồng lên hộp vừa giữ quá ngưỡng
order = [j for j in order if box_iou(boxes[i], boxes[j]) <= thr]
return sorted(keep)
M = np.array([[box_iou(a, b) for b in boxes] for a in boxes])
print(np.round(M, 2))
for thr in (0.5, 0.6, 0.8):
print(f"thr={thr}: keep {nms(thr)}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • Ma trận IoU: hai hộp của người A có IoU 0.78. Người B đứng sát nên hộp của B có IoU 0.54 với hộp 0 và 0.64 với hộp 1. Người C không chạm ai.
  • thr=0.5: giữ [0, 3]. Hộp 1 bị bỏ (đúng), nhưng hộp 2 cũng bị bỏ vì 0.54 vượt ngưỡng: người B biến mất.
  • thr=0.6: giữ [0, 2, 3], đúng ba người. Lưu ý hộp 1 bị hộp 0 xóa trước, nên IoU 0.64 giữa hộp 1 và hộp 2 không còn ảnh hưởng.
  • thr=0.8: giữ cả 4 hộp, người A bị đếm hai lần.

Không có ngưỡng nào đúng cho mọi cảnh. Mask R-CNN sống chung với điều này; DETR và Mask2Former tránh nó bằng cách ghép mỗi vật với đúng một truy vấn khi huấn luyện.

File dưới vẽ bằng PIL một ảnh 256×256256 \times 256: hai hình tròn đỏ chạm nhau (A, B) và một hình vuông xanh (C), cùng mask thật của từng hình. Bộ mã hóa ảnh chạy một lần để lấy embedding; sau đó mỗi hình nhận một điểm gợi ý ở giữa, và chỉ bộ giải mã chạy lại. Với mỗi điểm, code in diện tích ba mask SAM trả về, điểm iou_scores mô hình tự chấm, IoU thật so với mask đúng, và mask có iou_scores cao nhất. Lần đầu chạy, transformers tải checkpoint facebook/sam-vit-base (bản ViT-B, nhỏ nhất của SAM), khoảng 375 MB. Cách cài PyTorch bản CPU có ở bài Xây dựng LLM đầu tiên.

Chạy trên máy bạn

SAM: một embedding ảnh, ba điểm gợi ý

Cần cài
pip install torch transformers pillow
Phần cứng
CPU là đủ. Khoảng 1–2 phút trên máy phát triển khi đã có checkpoint. Bước attention của bộ mã hóa ảnh cần cấp một khối bộ nhớ khoảng 0.8 GB, nên hãy để trống vài GB RAM
import numpy as np
import torch
from PIL import Image, ImageDraw
from transformers import SamModel, SamProcessor
torch.manual_seed(0)
# Ảnh 256 x 256 tự vẽ: hai hình tròn chạm nhau và một hình vuông
img = Image.new("RGB", (256, 256), (235, 230, 220))
draw = ImageDraw.Draw(img)
draw.ellipse((20, 40, 100, 120), fill=(200, 60, 50)) # tròn A
draw.ellipse((100, 40, 180, 120), fill=(200, 60, 50)) # tròn B
draw.rectangle((60, 160, 140, 230), fill=(50, 90, 170)) # vuông C
# Mask thật, vẽ lại từng vật trên ảnh đen trắng
def true_mask(shape, box):
m = Image.new("L", (256, 256), 0)
getattr(ImageDraw.Draw(m), shape)(box, fill=1)
return np.array(m, dtype=bool)
gt = {"A": true_mask("ellipse", (20, 40, 100, 120)),
"B": true_mask("ellipse", (100, 40, 180, 120)),
"C": true_mask("rectangle", (60, 160, 140, 230))}
points = {"A": [60, 80], "B": [140, 80], "C": [100, 195]} # (x, y)
name = "facebook/sam-vit-base" # khoảng 375 MB
processor = SamProcessor.from_pretrained(name)
model = SamModel.from_pretrained(name).eval()
# Bộ mã hóa ảnh (ViT) chạy đúng một lần cho cả ảnh
inputs = processor(img, return_tensors="pt")
with torch.no_grad():
emb = model.get_image_embeddings(inputs["pixel_values"])
print("image embedding:", tuple(emb.shape))
for key, xy in points.items():
# Mỗi điểm gợi ý chỉ chạy lại phần giải mã nhẹ
p = processor(img, input_points=[[[xy]]], return_tensors="pt")
with torch.no_grad():
out = model(image_embeddings=emb,
input_points=p["input_points"])
masks = processor.image_processor.post_process_masks(
out.pred_masks, p["original_sizes"],
p["reshaped_input_sizes"])[0][0] # 3 mask cho 1 điểm
scores = out.iou_scores[0, 0]
ious = [(m.numpy() & gt[key]).sum() / (m.numpy() | gt[key]).sum()
for m in masks]
print(key, "area:", [int(m.sum()) for m in masks],
"truth:", int(gt[key].sum()))
print(" iou_scores:", np.round(scores.numpy(), 3))
print(" IoU vs truth:", np.round(ious, 3),
"best:", int(scores.argmax()))

Kết quả mẫu

Lấy từ một lần chạy thật.

image embedding: (1, 256, 64, 64)
A area: [10250, 10214, 5127] truth: 5145
iou_scores: [0.731 0.933 1.016]
IoU vs truth: [0.501 0.5 0.997] best: 2
B area: [11701, 10241, 5111] truth: 5145
iou_scores: [0.727 0.954 1.004]
IoU vs truth: [0.438 0.501 0.993] best: 2
C area: [15843, 5751, 5747] truth: 5751
iou_scores: [0.89 1.015 0.999]
IoU vs truth: [0.363 1. 0.999] best: 1

Đọc kết quả:

  • (1, 256, 64, 64): ảnh được phóng lên 1024×10241024 \times 1024 rồi thu 16 lần thành lưới 64×6464 \times 64, mỗi ô 256 kênh, đúng như bài báo mô tả. Ba điểm gợi ý dùng lại embedding này.
  • Điểm trên hình tròn A: mask thứ ba có diện tích 5127 pixel, gần bằng 5145 của mask thật, IoU 0.997. Hai mask đầu rộng khoảng gấp đôi (10250 và 10214 pixel) và có IoU với A khoảng 0.5: chúng phủ cả hai hình tròn. Đây đúng là sự mơ hồ SAM được thiết kế để xử lý: một điểm trên hình tròn đỏ có thể có nghĩa “hình tròn này” hoặc “khối đỏ gồm hai hình tròn chạm nhau”. iou_scores xếp mask đúng lên đầu (best: 2).
  • Hình tròn B: giống A, mask tốt nhất có IoU 0.993.
  • Hình vuông C: mask thứ hai khớp hoàn toàn (IoU 1.0, diện tích 5751 bằng đúng mask thật), và cũng là mask có điểm cao nhất. Mask đầu tiên rộng gần gấp ba mask thật, IoU chỉ 0.363.
  • iou_scores có thể lớn hơn 1 (1.016, 1.015): đây là đầu ra của một đầu hồi quy học bằng sai số bình phương, không bị chặn ở 1. Dùng nó để xếp hạng các mask, đừng coi là xác suất.

Để ý hai hình tròn chạm nhau, giống B và C của ô NumPy đầu. Một điểm gợi ý đủ để SAM tách từng hình, việc mà ndimage.label không làm được.

Câu 1/5 Ảnh đường phố có bầu trời, mặt đường và bốn chiếc xe. Hệ thống cần trả lời cùng lúc: có mấy xe, mỗi xe chiếm những pixel nào, và pixel nào là mặt đường. Bài toán nào khớp yêu cầu này?
Câu 2/5 Ảnh có hai người đứng sát nhau, mask thật của hai người rộng bằng nhau và không chồng lên nhau. Mô hình trả về đúng một mask phủ khít cả hai người. PQ của lớp người là bao nhiêu?
Câu 3/5 Bạn thay RoIAlign bằng RoIPool trong Mask R-CNN, giữ nguyên mọi thứ khác. Điều gì nhiều khả năng xảy ra?
Câu 4/5 Trong một cảnh đông người, hai người khác nhau đứng sát thường có hộp với IoU khoảng 0.6, còn các hộp trùng của cùng một người có IoU khoảng 0.85. Bạn đặt ngưỡng NMS là 0.5. Điều gì xảy ra?
Câu 5/5 Một công cụ gán nhãn dùng SAM. Người dùng bấm lần lượt 20 điểm trên cùng một ảnh. Phần nào của SAM phải chạy 20 lần?

Ý chính:

  • Semantic segmentation gán lớp cho pixel; instance segmentation tách từng vật đếm được (thing); panoptic làm cả hai, mỗi pixel một cặp (lớp, mã đối tượng). PQ ghép đoạn ở IoU lớn hơn 0.5 rồi tính PQ=SQ×RQ\text{PQ} = \text{SQ} \times \text{RQ}: độ khít của mask nhân với F1 của việc tìm vật.
  • Mask R-CNN thêm đầu mask 28×2828 \times 28 vào Faster R-CNN: tìm hộp, rồi tô mask trong hộp. RoIAlign bỏ phép làm tròn tọa độ của RoIPool bằng nội suy song tuyến tính, đủ để AP của mask tăng rõ.
  • Hướng phân loại mask: NN truy vấn, mỗi truy vấn một mask σ(ei⊤ph,w)\sigma(\mathbf{e}_i^\top \mathbf{p}_{h,w}) và một lớp, ghép đôi với vật thật khi huấn luyện nên không cần NMS. Mask2Former thêm masked attention (giá trị −∞-\infty ngoài vùng mask trước softmax) và dùng một kiến trúc cho cả ba bài toán.
  • SAM tách theo gợi ý: bộ mã hóa ảnh ViT chạy một lần, bộ giải mã nhẹ chạy cho mỗi điểm hay hộp, trả 3 mask kèm điểm IoU dự đoán khi gợi ý mơ hồ, học trên SA-1B (1.1 tỉ mask). SAM không cho nhãn lớp; SAM 2 mở rộng sang video bằng bộ nhớ.

Đọc thêm:

  • Kirillov, A., He, K., Girshick, R., Rother, C., Dollár, P. (2019). Panoptic Segmentation, arXiv:1801.00868, CVPR 2019, mục 3 và 4. Định nghĩa thing, stuff, bài toán panoptic, và chứng minh ngắn vì sao ngưỡng 0.5 cho ghép duy nhất.
  • He, K., Gkioxari, G., Dollár, P., Girshick, R. (2017). Mask R-CNN, arXiv:1703.06870, mục 3 và bảng 2. Đầu mask, RoIAlign, và các thí nghiệm bỏ từng thành phần.
  • Carion, N. và cộng sự (2020). End-to-End Object Detection with Transformers, arXiv:2005.12872, mục 3. Truy vấn vật thể và mất mát ghép đôi, nền của MaskFormer.
  • Cheng, B., Schwing, A. G., Kirillov, A. (2021). Per-Pixel Classification is Not All You Need for Semantic Segmentation, arXiv:2107.06278, NeurIPS 2021; và Cheng, B., Misra, I., Schwing, A. G., Kirillov, A., Girdhar, R. (2022). Masked-attention Mask Transformer for Universal Image Segmentation, arXiv:2112.01527, CVPR 2022, mục 3 và 4.6. MaskFormer, Mask2Former, và chính tác giả nói về giới hạn “vẫn huấn luyện riêng từng bài toán”.
  • Kirillov, A. và cộng sự (2023). Segment Anything, arXiv:2304.02643, mục 3 và 4; và Ravi, N. và cộng sự (2024). SAM 2: Segment Anything in Images and Videos, arXiv:2408.00714. Kiến trúc ba phần, xử lý mơ hồ, động cơ dữ liệu; và phần mở rộng sang video.

Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu