Skip to content

Semantic Segmentation: FCN, UNet, FPN, DeepLab, HRNet

This content is not available in your language yet.

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

  • Phát biểu được bài toán semantic segmentation (phân vùng ngữ nghĩa): gán một lớp cho từng pixel, đầu ra H×W×KH \times W \times K, học bằng cross-entropy theo pixel.
  • Tính được IoU (intersection over union) từng lớp, mIoU, pixel accuracy, và giải thích vì sao pixel accuracy đánh lừa khi các lớp mất cân bằng.
  • Giải thích được năm cách giải cùng một mâu thuẫn “biết cái gì” và “biết ở đâu”: FCN, UNet, FPN, DeepLab, HRNet. Tính được trường tiếp nhận của tích chập giãn (dilated convolution).
  • Phân biệt được nội suy lên song tuyến tính với tích chập chuyển vị (transposed convolution), và nhận ra hiện tượng ô bàn cờ.

Từ “trong ảnh có gì” tới “pixel này là gì”

Phần tiêu đề “Từ “trong ảnh có gì” tới “pixel này là gì””

Một mạng phân loại nhìn ảnh chụp đường phố và trả lời: “có ô tô”. Với xe tự lái, câu trả lời đó vô dụng. Xe cần biết ô tô nằm ở đâu, đường kéo dài tới đâu, mép vỉa hè ở pixel nào. Bác sĩ đọc ảnh chụp cắt lớp cũng vậy: “có khối u” chưa đủ, cần đường viền của khối u để đo thể tích.

Semantic segmentation trả lời câu hỏi đó cho từng pixel: pixel này là đường, pixel kia là người, pixel nọ là trời. Kết quả là một tấm “bản đồ tô màu” cùng cỡ với ảnh. Hai người đứng cạnh nhau được tô cùng một màu “người”: bài toán không đếm từng vật. Tách từng vật riêng là việc của bài Instance và Universal Segmentation.

Thử cách ngây thơ: lấy CNN phân loại, bỏ lớp cuối. Có hai vấn đề.

  • Tầng sâu thì thô. Sau 5 lần giảm nửa, lưới chỉ còn 1/321/32 cạnh ảnh. Mỗi ô của lưới 7×77 \times 7 ứng với một vùng 32×3232 \times 32 pixel: biết vùng đó “có mèo”, nhưng không biết đường viền con mèo đi qua pixel nào.
  • Tầng nông thì mù nghĩa. Bản đồ đặc trưng ở tầng đầu còn đủ chi tiết, nhưng chỉ “thấy” vài cạnh và đốm màu, chưa biết đó là mèo hay chó.

Giống như nhìn bản đồ: bản đồ thế giới cho bạn biết đây là Việt Nam, nhưng không chỉ được con hẻm. Bản đồ phường chỉ được con hẻm, nhưng nếu chỉ có nó, bạn không biết mình ở nước nào. Người tìm đường giỏi dùng cả hai.

Cả năm họ mô hình trong bài là năm cách kết hợp hai loại thông tin này. Bài toán của bài là: làm sao dựng một mạng nhận ảnh H×WH \times W và trả ra một nhãn cho từng pixel, vừa đúng nghĩa vừa đúng đường viền, và đo chất lượng của nó cho công bằng với cả những lớp nhỏ?

Ảnh vào X∈RH×W×C\mathbf{X} \in \mathbb{R}^{H \times W \times C} như bài trước. Có KK lớp (tính cả nền). Nhãn đúng là một mask M\mathbf{M} cỡ H×WH \times W, mỗi ô Mij∈{0,1,…,K−1}M_{ij} \in \{0, 1, \dots, K-1\} là lớp của pixel ở hàng ii, cột jj.

Mạng trả ra một khối điểm S∈RH×W×K\mathbf{S} \in \mathbb{R}^{H \times W \times K}: mỗi pixel có KK logit, một cho mỗi lớp. Softmax áp riêng cho từng pixel biến KK logit đó thành xác suất:

pijk=eSijk∑c=0K−1eSijcp_{ijk} = \frac{e^{S_{ijk}}}{\sum_{c=0}^{K-1} e^{S_{ijc}}}

Nhãn đoán của pixel là lớp có xác suất lớn nhất, y^ij=arg⁡max⁡kpijk\hat{y}_{ij} = \arg\max_k p_{ijk}.

So với phân loại ảnh, chỉ có một thay đổi: thay vì một vectơ KK số cho cả ảnh, ta có H⋅WH \cdot W vectơ như thế. Mỗi pixel là một bài phân loại nhỏ.

Vì mỗi pixel là một bài phân loại, ta dùng cross-entropy cho từng pixel rồi lấy trung bình trên cả ảnh:

L=−1HW∑i=1H∑j=1Wln⁡pijMij\mathcal{L} = -\frac{1}{HW} \sum_{i=1}^{H} \sum_{j=1}^{W} \ln p_{i j M_{ij}}

Đọc từ trong ra ngoài: pijMijp_{ijM_{ij}} là xác suất mạng gán cho lớp đúng của pixel (i,j)(i, j). Lấy −ln⁡-\ln thì xác suất gần 1 cho mất mát gần 0, xác suất gần 0 cho mất mát rất lớn. Chia cho HWHW để mất mát không phụ thuộc cỡ ảnh. Với một lô nhiều ảnh, F.cross_entropy của PyTorch lấy trung bình trên mọi pixel của mọi ảnh. FCN viết tổng thay vì trung bình; hai cách chỉ khác một hằng số nhân.

Công thức này có một hệ quả: lớp nhiều pixel chiếm phần lớn tổng. Nếu 80% pixel là nền, mạng giảm được phần lớn mất mát chỉ bằng cách đoán “nền” giỏi. UNet xử lý bằng cách nhân mất mát mỗi pixel với một trọng số wijw_{ij}: lớn hơn ở lớp hiếm và ở đường biên mỏng giữa hai tế bào chạm nhau.

Cách đo đơn giản nhất là pixel accuracy: tỉ lệ pixel đoán đúng.

acc=soˆˊ pixel coˊ y^ij=MijHW\text{acc} = \frac{\text{số pixel có } \hat{y}_{ij} = M_{ij}}{HW}

Nó có cùng nhược điểm với hàm mất mát ở trên. Ô NumPy ở mục Ví dụ code dùng ảnh 64×6464 \times 64 có 3 323 pixel nền, 709 pixel hình tròn và chỉ 64 pixel hình vuông nhỏ. Một mô hình bỏ sót hoàn toàn hình vuông vẫn có pixel accuracy 4 032/4 096≈0.9844\,032 / 4\,096 \approx 0.984.

IoU sửa điều đó bằng cách đo từng lớp riêng. Gọi AkA_k là tập pixel đoán là lớp kk, BkB_k là tập pixel thật sự thuộc lớp kk:

IoUk=∣Ak∩Bk∣∣Ak∪Bk∣=TPkTPk+FPk+FNk\text{IoU}_k = \frac{|A_k \cap B_k|}{|A_k \cup B_k|} = \frac{\text{TP}_k}{\text{TP}_k + \text{FP}_k + \text{FN}_k}

Tử số là phần giao: pixel đoán đúng là kk (true positive). Mẫu số là phần hợp, tách được thành ba phần không chồng nhau: đúng là kk và đoán là kk (TP), đoán là kk mà sai (false positive), là kk mà bị bỏ sót (false negative). IoU bằng 1 khi hai vùng trùng khít, bằng 0 khi không chạm nhau. Nó phạt cả đoán thừa lẫn đoán thiếu.

Với mô hình bỏ sót hình vuông: lớp vuông có TP=0\text{TP} = 0 nên IoU=0\text{IoU} = 0. Lớp nền có TP=3 323\text{TP} = 3\,323, và 64 pixel vuông bị đoán thành nền là FP của lớp nền, nên IoUneˆˋn=3 323/3 387≈0.981\text{IoU}_\text{nền} = 3\,323 / 3\,387 \approx 0.981.

mIoU (mean IoU) là trung bình cộng IoU của KK lớp, mỗi lớp cùng trọng số dù to hay nhỏ:

mIoU=1K∑k=0K−1IoUk\text{mIoU} = \frac{1}{K} \sum_{k=0}^{K-1} \text{IoU}_k

Mô hình trên có mIoU=(0.981+1+0)/3≈0.660\text{mIoU} = (0.981 + 1 + 0)/3 \approx 0.660, thấp hơn hẳn 0.984. Đây là độ đo chính của các bài báo segmentation; FCN gọi nó là “mean IU”. Trên một tập kiểm tra, cách chuẩn là cộng dồn TP, FP, FN của mọi ảnh rồi mới chia, như ô PyTorch làm.

FCN: biến mạng phân loại thành mạng “toàn tích chập”

Phần tiêu đề “FCN: biến mạng phân loại thành mạng “toàn tích chập””

FCN (Long, Shelhamer, Darrell, 2015) là bài báo mở đầu cho cách làm hiện đại. Ba ý:

Ý 1: thay lớp kết nối đầy đủ bằng tích chập. Một lớp kết nối đầy đủ nhận bản đồ 7×7×5127 \times 7 \times 512 chính là một tích chập có kernel 7×77 \times 7 phủ trọn bản đồ đó. Viết lại như vậy, mạng không còn đòi ảnh cỡ cố định. Đưa ảnh to hơn vào, thay vì một vectơ điểm, bạn nhận một lưới điểm: mỗi ô là dự đoán cho một vùng ảnh. Mạng chỉ gồm tích chập nên được gọi là mạng toàn tích chập (fully convolutional network). Cuối cùng, một tích chập 1×11 \times 1 đổi số kênh thành KK (21 với bộ PASCAL VOC: 20 lớp và nền).

Ý 2: nội suy lên. Lưới điểm của VGG16 nhỏ hơn ảnh 32 lần mỗi chiều. Tỉ lệ này gọi là output stride. FCN phóng lưới lên đúng cỡ ảnh bằng tích chập chuyển vị, khởi tạo đúng bằng nội suy song tuyến tính (mục sau). Bản này gọi là FCN-32s, và đường viền của nó rất “nhòe”.

Ý 3: nối tắt từ tầng nông. FCN-16s lấy bản đồ ở pool4 (output stride 16), cho qua tích chập 1×11 \times 1 thành KK kênh điểm, rồi cộng với lưới điểm của tầng cuối đã phóng lên 2 lần. FCN-8s làm thêm một lần với pool3 (output stride 8). Tầng sâu nói “cái gì”, tầng nông chỉnh lại “ở đâu”.

Nội suy lên: song tuyến tính và tích chập chuyển vị

Phần tiêu đề “Nội suy lên: song tuyến tính và tích chập chuyển vị”

Có ba cách phóng một lưới h×wh \times w lên sh×swsh \times sw:

  • Láng giềng gần nhất (nearest neighbor): mỗi ô thô chép ra thành khối s×ss \times s. Nhanh, nhưng ra hình bậc thang.
  • Nội suy song tuyến tính (bilinear interpolation): mỗi pixel mới là trung bình có trọng số của 4 ô thô gần nhất, trọng số tỉ lệ với độ gần. Nội suy theo hàng trước, theo cột sau, nên gọi là “song” tuyến tính. Đường viền mượt hơn. Không có tham số học.
  • Tích chập chuyển vị (transposed convolution, có bài gọi là “deconvolution”): mỗi ô vào nhân với một kernel k×kk \times k học được rồi “đóng dấu” lên lưới ra, cách nhau ss ô; chỗ các dấu chồng nhau thì cộng lại. Kích thước ra (PyTorch, không có output_padding) là (h−1)s−2p+k(h - 1)s - 2p + k. Với k=s=2k = s = 2, p=0p = 0: (h−1)⋅2+2=2h(h-1) \cdot 2 + 2 = 2h, gấp đôi, như UNet và ô PyTorch dùng.

Tích chập chuyển vị có một bẫy, Odena, Dumoulin và Olah (2016) gọi là hiện tượng ô bàn cờ (checkerboard artifacts). Khi kk không chia hết cho ss, ví dụ k=3k = 3, s=2s = 2, các dấu chồng nhau không đều: theo mỗi chiều, cứ một pixel ra nhận đóng góp từ hai ô vào thì pixel kế bên chỉ nhận từ một. Trên ảnh hai chiều, hoa văn này thành ô bàn cờ, và xếp chồng nhiều lớp làm nó nặng thêm. Cách sửa của họ: nội suy trước (gần nhất hoặc song tuyến tính), rồi tích chập thường. Chọn kk chia hết cho ss giảm vấn đề, nhưng tác giả lưu ý không hết hẳn.

UNet (Ronneberger, Fischer, Brox, 2015) ra đời cho ảnh kính hiển vi, nơi chỉ có vài chục ảnh có nhãn và đường biên giữa hai tế bào chỉ dày vài pixel. Thiết kế gồm hai nhánh:

  • Nhánh co (contracting path, hay encoder): lặp lại “hai tích chập 3×33 \times 3 + ReLU, rồi max pooling 2×22 \times 2”. Mỗi lần giảm nửa độ phân giải thì số kênh gấp đôi: 64, 128, 256, 512, 1024.
  • Nhánh giãn (expansive path, hay decoder): lặp lại “phóng lên 2 lần bằng tích chập chuyển vị 2×22 \times 2 (giảm nửa số kênh), nối kênh với bản đồ cùng tầng của nhánh co, rồi hai tích chập 3×33 \times 3”. Cuối cùng một tích chập 1×11 \times 1 ra KK kênh.

Điểm khác FCN nằm ở hai chữ: đối xứng và nối kênh. FCN có một bộ giải mã rất mỏng (cộng điểm KK kênh). UNet có nhánh giãn dày gần bằng nhánh co, và ở mỗi tầng nó nối (concatenate) toàn bộ đặc trưng của nhánh co vào, không chỉ KK kênh điểm. Đó là nối tắt (skip connection): chi tiết vị trí đi thẳng từ trái sang phải chữ U, không phải đi qua đáy chữ U nơi độ phân giải thấp nhất.

Bài báo gốc dùng tích chập không đệm, nên mỗi lớp mất một viền pixel: ô vào 572×572572 \times 572 cho mask 388×388388 \times 388, và bản đồ của nhánh co phải cắt mép trước khi nối. Đa số cài đặt sau này dùng padding 1 để ra đúng cỡ ảnh, như ô PyTorch của bài.

FPN (Lin và cộng sự, 2017) vốn sinh ra cho bài toán phát hiện đối tượng, nơi vật có thể to hay nhỏ. Nó có ba đường:

  1. Từ dưới lên (bottom-up): xương sống CNN bình thường, cho các bản đồ ở output stride 4, 8, 16, 32.
  2. Từ trên xuống (top-down): bắt đầu từ bản đồ sâu nhất, phóng lên 2 lần bằng láng giềng gần nhất.
  3. Nối ngang (lateral connection): bản đồ cùng tầng của đường đi lên qua tích chập 1×11 \times 1 để có d=256d = 256 kênh, rồi cộng với bản đồ vừa phóng lên. Sau đó một tích chập 3×33 \times 3 làm mịn, theo bài báo là để giảm răng cưa do phóng lên.

Kết quả là một kim tự tháp (pyramid): mỗi tầng có độ phân giải riêng nhưng đều mang nghĩa của tầng sâu nhất. Panoptic FPN (Kirillov và cộng sự, 2019) gắn thêm một nhánh semantic segmentation nhẹ lên kim tự tháp này. So với UNet: FPN dùng phép cộng với số kênh cố định, nên rẻ hơn nối kênh; và nó giữ mọi tầng làm đầu ra, không chỉ tầng cuối.

DeepLab: đừng thu nhỏ quá, hãy giãn kernel

Phần tiêu đề “DeepLab: đừng thu nhỏ quá, hãy giãn kernel”

Họ DeepLab của Chen và cộng sự đi hướng khác: thay vì thu nhỏ 32 lần rồi cố gắng phóng lại, bớt thu nhỏ ngay từ đầu. Bỏ stride ở vài tầng cuối thì output stride còn 16 hoặc 8. Nhưng làm vậy, mỗi kernel 3×33 \times 3 ở tầng sâu chỉ còn “thấy” một vùng ảnh nhỏ hơn trước. Cần một cách cho kernel nhìn xa hơn mà không thêm tham số.

Tích chập giãn (dilated hay atrous convolution, “à trous” là “có lỗ” trong tiếng Pháp) làm đúng điều đó. Viết một chiều cho gọn, theo DeepLabv3 (2017):

y[i]=∑tx[i+r⋅t] w[t]y[i] = \sum_{t} x[i + r \cdot t]\, w[t]

Ở đây w[t]w[t] là trọng số thứ tt của kernel, và dilation rate rr là khoảng cách giữa hai vị trí lấy mẫu. r=1r = 1 là tích chập thường. r=2r = 2 nghĩa là lấy mẫu cách một ô.

Kích thước hiệu dụng. Kernel có kk trọng số, giữa hai trọng số liền nhau có r−1r - 1 ô bị bỏ qua. Có k−1k - 1 khoảng như vậy, nên tổng chiều dài phủ là:

keff=k+(k−1)(r−1)k_\text{eff} = k + (k - 1)(r - 1)

Với k=3k = 3: r=2r = 2 cho keff=5k_\text{eff} = 5, r=6r = 6 cho 13, r=18r = 18 cho 37. Số trọng số vẫn là 3×3=93 \times 3 = 9.

Trường tiếp nhận cộng dồn. Trường tiếp nhận (receptive field) là vùng đầu vào ảnh hưởng tới một ô ra. Với các lớp stride 1 xếp chồng, mỗi lớp nới trường tiếp nhận thêm keff−1k_\text{eff} - 1 ô, vì nó gom keffk_\text{eff} ô kề nhau của lớp trước, mà mỗi ô đó đã thấy một vùng, hai vùng kề nhau lệch nhau một ô. Bắt đầu từ 1 ô:

R=1+∑ℓ(keff,ℓ−1)R = 1 + \sum_{\ell} (k_{\text{eff},\ell} - 1)

Ba lớp 3×33 \times 3 thường cho R=1+2+2+2=7R = 1 + 2 + 2 + 2 = 7. Ba lớp 3×33 \times 3 với r=1,2,4r = 1, 2, 4 cho R=1+2+4+8=15R = 1 + 2 + 4 + 8 = 15 (tự tính), cùng số tham số. Trường tiếp nhận lớn lên theo cấp số nhân thay vì cộng, mà độ phân giải không giảm.

ASPP. Vật có nhiều cỡ, nên một rate không đủ. ASPP (Atrous Spatial Pyramid Pooling) chạy song song nhiều nhánh trên cùng bản đồ rồi nối kênh kết quả. Ở DeepLabv3 với output stride 16: một tích chập 1×11 \times 1, ba tích chập 3×33 \times 3 với rate 6, 12, 18, và một nhánh đặc trưng mức ảnh (lấy trung bình toàn bản đồ, tích chập 1×11 \times 1, phóng lại). Nhánh cuối có lý do riêng: khi rate gần bằng cỡ bản đồ, phần lớn 9 vị trí lấy mẫu rơi ra ngoài vùng có dữ liệu, kernel 3×33 \times 3 thoái hóa thành 1×11 \times 1; nhánh trung bình toàn ảnh bù lại ngữ cảnh toàn cục.

DeepLabv3+ (Chen và cộng sự, 2018) thêm một bộ giải mã nhỏ kiểu UNet để sửa đường biên, tức là ghép hai ý tưởng lại.

HRNet: không bao giờ bỏ độ phân giải cao

Phần tiêu đề “HRNet: không bao giờ bỏ độ phân giải cao”

Các họ trên đều theo một mẫu: thu nhỏ rồi phục hồi. HRNet (Wang và cộng sự, 2020) hỏi: sao phải đánh mất độ phân giải cao để rồi lấy lại?

HRNet giữ một nhánh độ phân giải cao (1/4 cạnh ảnh) suốt chiều dài mạng. Mỗi giai đoạn thêm một nhánh mới thấp hơn, tới bốn nhánh song song ở 1/4, 1/8, 1/16, 1/32, số kênh CC, 2C2C, 4C4C, 8C8C. Định kỳ, các nhánh trao đổi thông tin: nhánh cao gửi xuống qua tích chập 3×33 \times 3 stride 2, nhánh thấp gửi lên qua nội suy song tuyến tính và tích chập 1×11 \times 1, mỗi nhánh cộng mọi thứ nhận được. Với segmentation, đầu HRNetV2 phóng mọi nhánh lên 1/4 rồi nối kênh.

Hình dung lại hai tấm bản đồ: UNet đọc bản đồ thế giới rồi mới lần xuống bản đồ phường; HRNet trải cả bốn tấm trên bàn và liên tục đối chiếu.

Hình dưới vẽ UNet nhỏ đúng như ô PyTorch của bài. Hãy nhìn các mũi tên ngang: chúng mang bản đồ 32×3232 \times 32 và 16×1616 \times 16 của nhánh co sang nhánh giãn, bỏ qua đáy chữ U chỉ còn 8×88 \times 8.

Bảng dưới đặt năm họ cạnh nhau theo một câu hỏi chung: mỗi họ lấy lại độ phân giải và ghép “cái gì” với “ở đâu” bằng cách nào.

Tiêu chíFCN (2015)UNet (2015)FPN (2017)DeepLab v3/v3+ (2017–2018)HRNet (2020)
Đường đi của độ phân giảiThu nhỏ 32 lần, phóng lên một lầnThu nhỏ rồi phóng lên từng tầng, đối xứngThu nhỏ, rồi đi ngược từ trên xuống từng tầngChỉ thu nhỏ 8 hoặc 16 lầnGiữ nhánh 1/4 suốt mạng, chạy song song nhánh thấp
Ghép chi tiết với ngữ cảnhCộng điểm KK kênh từ pool3, pool4Nối kênh toàn bộ đặc trưng cùng tầngTích chập 1×11 \times 1 rồi cộng, d=256d = 256 kênhAtrous và ASPP nhiều rate; v3+ thêm bộ giải mãTrao đổi lặp lại giữa các nhánh
Phóng lên bằngTích chập chuyển vị, khởi tạo song tuyến tínhTích chập chuyển vị 2×22 \times 2Láng giềng gần nhấtSong tuyến tínhSong tuyến tính
Điểm mạnhĐơn giản, dùng lại mạng phân loạiĐường biên sắc, học được từ ít ảnhĐặc trưng nhiều tỉ lệ, rẻ, dùng chung cho phát hiệnTrường tiếp nhận lớn, ngữ cảnh nhiều cỡBản đồ cao vừa sắc vừa giàu nghĩa
Cái giáĐường viền nhòeTốn bộ nhớ ở các tầng độ phân giải caoCần thêm đầu ra cho segmentationTính toán nặng ở độ phân giải 1/8Nhiều nhánh, tốn bộ nhớ và tính toán

Code trong bài chạy thử lần cuối tháng 11/2026: ô NumPy với Python 3.14 và NumPy 2.4 (bản có trong Pyodide), ô PyTorch với Python 3.14 và PyTorch 2.15 trên CPU. Ô NumPy chạy ngay trên trang, ô PyTorch chạy trên máy bạn. Hai ô độc lập, không dùng biến của nhau.

Ô dưới đây vẽ một ảnh 64×6464 \times 64 có ba lớp: nền, một hình tròn bán kính 15 và một hình vuông 8×88 \times 8. Hàm report dựng ma trận nhầm lẫn (confusion matrix) K×KK \times K bằng np.bincount: ô (i,j)(i, j) đếm số pixel lớp đúng ii bị đoán là jj. Đường chéo là TP; tổng cột trừ TP là FP; tổng hàng trừ TP là FN. Ô thử bốn “mô hình”:

  • all bg: đoán nền ở mọi pixel.
  • no square: đúng hết, trừ bỏ sót cả hình vuông.
  • Mô phỏng một mạng có output stride s=4s = 4 và s=8s = 8: lấy trung bình mask one-hot trên từng ô s×ss \times s làm lưới điểm thô, rồi phóng lên bằng láng giềng gần nhất (nearest) hoặc song tuyến tính (bilin), và lấy argmax.

IoU, mIoU, pixel accuracy và nội suy lên

import numpy as np
# Ảnh 64x64 có ba lớp: 0 nền, 1 hình tròn, 2 hình vuông nhỏ
H = W = 64
K = 3
yy, xx = np.mgrid[0:H, 0:W]
gt = np.zeros((H, W), dtype=int) # mask đúng
gt[(yy - 24) ** 2 + (xx - 24) ** 2 <= 15 ** 2] = 1
gt[44:52, 46:54] = 2
print("pixels per class:", np.bincount(gt.ravel()))
def report(name, pred):
# C[i, j]: số pixel lớp đúng i bị đoán là lớp j (ma trận nhầm lẫn)
C = np.bincount(K * gt.ravel() + pred.ravel(), minlength=K * K)
C = C.reshape(K, K)
tp = np.diag(C) # phần giao của mask đoán và mask đúng, từng lớp
iou = tp / (C.sum(0) + C.sum(1) - tp) # giao / hợp
acc = tp.sum() / C.sum() # pixel accuracy
print(f"{name:9s} acc = {acc:.3f} IoU = {np.round(iou, 3)} "
f"mIoU = {iou.mean():.3f}")
report("all bg", np.zeros_like(gt))
report("no square", np.where(gt == 2, 0, gt)) # bỏ sót cả hình vuông
for s in [4, 8]: # output stride: bản đồ điểm nhỏ hơn ảnh s lần mỗi chiều
onehot = np.eye(K)[gt] # (64, 64, 3)
# Điểm thô: trung bình one-hot trên từng ô s x s
coarse = onehot.reshape(H // s, s, W // s, s, K).mean(axis=(1, 3))
near = coarse.repeat(s, axis=0).repeat(s, axis=1) # gần nhất
# Song tuyến tính: vị trí tâm pixel trên lưới thô, nội suy từng trục
c = (np.arange(H) + 0.5) / s - 0.5
f = np.floor(c).astype(int)
i0, i1 = np.clip(f, 0, H // s - 1), np.clip(f + 1, 0, H // s - 1)
t = (c - f)[:, None, None]
rows = coarse[i0] * (1 - t) + coarse[i1] * t # nội suy theo hàng
bil = rows[:, i0] * (1 - t[:, 0]) + rows[:, i1] * t[:, 0] # theo cột
report(f"nearest/{s}", near.argmax(-1))
report(f"bilin/{s}", bil.argmax(-1))

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • all bg: pixel accuracy 0.811 chỉ nhờ nền chiếm 81% ảnh, nhưng mIoU 0.270.
  • no square: pixel accuracy 0.984, IoU hình vuông 0, mIoU 0.660. Đúng các số đã tính tay ở mục Lý thuyết. Lỗi nghiêm trọng nhất (mất hẳn một vật) gần như không thấy trên pixel accuracy.
  • Output stride 4: song tuyến tính cho mIoU 0.925, láng giềng gần nhất 0.771. Phóng bằng láng giềng gần nhất làm đường tròn thành bậc thang (IoU 0.854 so với 0.972).
  • Output stride 8: hình vuông 8×88 \times 8 lệch lưới nên bị chia ra nhiều ô thô, ô nào cũng nhiều nền hơn vuông. Cả hai cách phóng đều cho IoU hình vuông bằng 0, trong khi pixel accuracy vẫn 0.942 và 0.974. Thông tin đã mất ở lưới thô thì không phép nội suy nào lấy lại được. Đó là lý do FCN, UNet, FPN đưa chi tiết từ tầng nông sang, còn DeepLab và HRNet giữ độ phân giải cao hơn.

Ô này nội suy điểm của 3 lớp rồi mới argmax, như FCN làm. Đừng nội suy chính mask nhãn (xem Lỗi hay gặp).

File dưới đây tự sinh 512 ảnh huấn luyện và 128 ảnh kiểm tra 32×3232 \times 32 (hạt giống 0). Mỗi ảnh xám có nhiễu, chứa một hình tròn và một hình vuông cùng độ sáng, nên mạng không thể chỉ nhìn độ sáng: phải nhận ra hình dạng. Mạng là UNet hai tầng như hình ở mục Minh họa, phóng lên bằng ConvTranspose2d(…, 2, 2) (kernel bằng stride, không chồng lấn). File huấn luyện hai lần với cùng hạt giống: một lần đủ nối tắt, một lần thay nối tắt bằng số 0, rồi in pixel accuracy, IoU từng lớp và mIoU trên tập kiểm tra. Cách cài PyTorch bản CPU có ở bài Xây dựng LLM đầu tiên.

Run on your machine

UNet hai tầng học tách hình tròn và hình vuông

Install
pip install torch numpy
Hardware
CPU là đủ. Một đến hai phút cho cả hai lần huấn luyện trên máy phát triển
import numpy as np
import torch
import torch.nn.functional as F
from torch import nn
torch.manual_seed(0)
rng = np.random.default_rng(0)
S, K = 32, 3 # cạnh ảnh; số lớp: 0 nền, 1 hình tròn, 2 hình vuông
def make(n): # n ảnh xám có nhiễu, mỗi ảnh một hình tròn, một hình vuông
yy, xx = np.mgrid[0:S, 0:S]
X = rng.normal(0.2, 0.1, (n, 1, S, S))
M = np.zeros((n, S, S), dtype=np.int64)
for i in range(n):
cy, cx, r = *rng.integers(8, 24, 2), rng.integers(4, 9)
M[i][(yy - cy) ** 2 + (xx - cx) ** 2 <= r * r] = 1
y, x, a = *rng.integers(0, 22, 2), rng.integers(6, 11)
M[i, y:y + a, x:x + a] = 2 # hình vuông đè lên nếu chồng nhau
X[i, 0][M[i] > 0] += rng.uniform(0.4, 0.8) # hai hình cùng độ sáng
return torch.tensor(X, dtype=torch.float32), torch.tensor(M)
def block(i, o): # hai lớp tích chập 3x3, padding 1 giữ nguyên kích thước
return nn.Sequential(nn.Conv2d(i, o, 3, padding=1), nn.ReLU(),
nn.Conv2d(o, o, 3, padding=1), nn.ReLU())
class TinyUNet(nn.Module): # 2 tầng: 32x32 -> 16x16 -> 8x8 rồi đi lên
def __init__(self, skip):
super().__init__()
self.skip = float(skip) # 0.0: thay nhánh nối tắt bằng số 0
self.e1, self.e2, self.mid = block(1, 16), block(16, 32), block(32, 64)
self.u2, self.d2 = nn.ConvTranspose2d(64, 32, 2, 2), block(64, 32)
self.u1, self.d1 = nn.ConvTranspose2d(32, 16, 2, 2), block(32, 16)
self.head = nn.Conv2d(16, K, 1) # K điểm cho từng pixel
def forward(self, x):
e1 = self.e1(x) # (B, 16, 32, 32)
e2 = self.e2(F.max_pool2d(e1, 2)) # (B, 32, 16, 16)
m = self.mid(F.max_pool2d(e2, 2)) # (B, 64, 8, 8)
d2 = self.d2(torch.cat([self.u2(m), e2 * self.skip], 1))
d1 = self.d1(torch.cat([self.u1(d2), e1 * self.skip], 1))
return self.head(d1) # (B, K, 32, 32)
(Xtr, Mtr), (Xte, Mte) = make(512), make(128) # tập huấn luyện, kiểm tra
for skip in [True, False]:
torch.manual_seed(0)
net = TinyUNet(skip)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
for epoch in range(20):
for b in torch.randperm(512).split(32):
loss = F.cross_entropy(net(Xtr[b]), Mtr[b]) # TB mọi pixel
opt.zero_grad()
loss.backward()
opt.step()
with torch.no_grad():
P = net(Xte).argmax(1) # lớp đoán cho từng pixel
iou = [((P == k) & (Mte == k)).sum().item()
/ ((P == k) | (Mte == k)).sum().item() for k in range(K)]
print(f"skip={skip}: acc {(P == Mte).float().mean():.3f}, IoU "
f"{np.round(iou, 3)}, mIoU {np.mean(iou):.3f}")

Sample output

From one real run. A model gives a different answer each time.

skip=True: acc 0.987, IoU [0.996 0.879 0.83 ], mIoU 0.902
skip=False: acc 0.953, IoU [0.983 0.676 0.46 ], mIoU 0.706

Đọc kết quả:

  • Có nối tắt: mIoU 0.902 trên 128 ảnh chưa thấy khi huấn luyện; hình tròn 0.879, hình vuông 0.83. Sau 20 lượt qua dữ liệu, UNet nhỏ đã phân biệt được hai hình cùng độ sáng chỉ nhờ hình dạng.
  • Bỏ nối tắt: cùng kiến trúc, cùng hạt giống, chỉ khác là nhánh giãn không nhận bản đồ của nhánh co. mIoU tụt còn 0.706, IoU hình vuông còn 0.46. Mọi thông tin vị trí phải đi qua đáy 8×88 \times 8, nên các góc vuông và đường biên bị nhòe, đúng điều mục Lý thuyết dự đoán.
  • Pixel accuracy chỉ giảm từ 0.987 xuống 0.953: lại thêm một ví dụ cho thấy nó che mất khác biệt mà mIoU thấy rõ.

Dù đã đặt hạt giống, số có thể lệch ở chữ số thứ hai, thứ ba trên máy khác: số luồng CPU đổi thứ tự cộng số thực, và sai khác nhỏ lớn dần qua hàng trăm bước huấn luyện. Trên máy phát triển, chạy với 4 luồng cho mIoU 0.908 và 0.704. Khoảng cách giữa có và không có nối tắt thì giữ nguyên.

Question 1 of 5 Ảnh đường phố có 4 lớp: đường 60% số pixel, trời 25%, nhà 13%, người đi bộ 2%. Một mô hình đoán đúng mọi pixel, trừ pixel người đi bộ: nó gán hết chúng cho lớp đường. Điều nào đúng?
Question 2 of 5 Một tích chập 3×33 \times 3 có dilation rate r=4r = 4. Kernel này phủ vùng cỡ nào trên bản đồ đặc trưng, và có bao nhiêu trọng số (một kênh vào, một kênh ra, bỏ qua bias)?
Question 3 of 5 Ở UNet của ô PyTorch, khối d2 nhận ghép kênh của u2(m) (32 kênh) và e2 (32 kênh), nên có 64 kênh vào. Nếu đổi phép nối kênh thành phép cộng từng phần tử như FPN, khối d2 phải nhận bao nhiêu kênh vào?
Question 4 of 5 Bạn thu nhỏ một ảnh và mask nhãn của nó (0 nền, 1 xe, 2 người) từ 512×512512 \times 512 xuống 256×256256 \times 256 trước khi huấn luyện. Cách nào đúng cho mask?
Question 5 of 5 Lớp nội suy lên của bạn là tích chập chuyển vị kernel 3, stride 2. Ảnh ra có hoa văn ô bàn cờ mờ. Cách sửa nào nhắm đúng nguyên nhân?

Ý chính:

  • Semantic segmentation là phân loại từng pixel: mạng ra H×W×KH \times W \times K logit, softmax riêng từng pixel, học bằng cross-entropy lấy trung bình trên mọi pixel.
  • Đo bằng IoUk=TPk/(TPk+FPk+FNk)\text{IoU}_k = \text{TP}_k / (\text{TP}_k + \text{FP}_k + \text{FN}_k) và mIoU (trung bình đều các lớp). Pixel accuracy bị lớp lớn chi phối và che mất việc bỏ sót lớp nhỏ.
  • Mâu thuẫn trung tâm: tầng sâu biết “cái gì” nhưng thô, tầng nông biết “ở đâu” nhưng ít nghĩa. FCN cộng điểm từ tầng nông; UNet dựng nhánh giãn đối xứng và nối kênh; FPN dựng kim tự tháp từ trên xuống bằng phép cộng; DeepLab bớt thu nhỏ và bù trường tiếp nhận bằng tích chập giãn (keff=k+(k−1)(r−1)k_\text{eff} = k + (k-1)(r-1)) và ASPP; HRNet giữ nhánh độ phân giải cao suốt mạng.
  • Phóng lên bằng song tuyến tính không có tham số; tích chập chuyển vị học được, nhưng dễ tạo ô bàn cờ khi kernel không chia hết cho stride.

Đọc thêm:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About