Học AI & Lập trình

Kiểm thử hàm xử lý ảnh với pytest: bắt lỗi trước khi lên máy

Dùng fixture, parametrize, pytest.raises và pytest.approx để khóa chặt các hàm cắt ROI, đếm pixel, đánh giá OK/NG, bắt lỗi ngưỡng trước khi lên máy trạm.

Kiểm thử hàm xử lý ảnh với pytest: bắt lỗi trước khi lên máy
Nội dung bài viết
  1. Vì sao kỹ sư Vision cần viết test?
  2. Cài đặt
  3. Code cần kiểm thử
  4. Viết test đầu tiên
  5. Chạy test
  6. Khi test bắt được lỗi
  7. Các lệnh hay dùng
  8. Nên test gì trong code xử lý ảnh?
  9. Tổ chức thư mục gợi ý

Bạn vừa sửa một ngưỡng trong hàm đánh giá OK/NG, chạy thử vài ảnh thấy ổn, đẩy lên máy trạm. Hai ngày sau, ca đêm báo máy loại nhầm hàng loạt sản phẩm đúng ở mức ranh giới. Lỗi này hoàn toàn có thể bắt được trong vài giây nếu có kiểm thử tự động. Bài này hướng dẫn dùng pytest, công cụ kiểm thử phổ biến nhất của Python, để bảo vệ các hàm xử lý ảnh của bạn.

Vì sao kỹ sư Vision cần viết test?

  • Code thay đổi liên tục: đổi ngưỡng, thêm điều kiện, tối ưu tốc độ. Mỗi lần sửa đều có thể làm hỏng thứ đang chạy đúng.
  • Lỗi ở ranh giới rất khó thấy bằng mắt: < hay <=, ROI sát biên ảnh, ảnh màu thay vì ảnh xám.
  • Test là tài liệu sống: đọc test là biết hàm được dùng thế nào và kỳ vọng ra sao.

Mục tiêu không phải viết thật nhiều test, mà là khóa chặt những hành vi quan trọng: kích thước đầu ra, cách xử lý dữ liệu sai, và các giá trị ngưỡng.

Cài đặt

pip install pytest numpy

Quy ước của pytest rất đơn giản: file tên bắt đầu bằng test_, hàm test tên bắt đầu bằng test_, bên trong dùng câu lệnh assert bình thường của Python. Không cần kế thừa lớp nào.

Code cần kiểm thử

Tạo file xu_ly.py chứa ba hàm nhỏ thường gặp trong một trạm kiểm tra: cắt ROI, tính tỉ lệ pixel tối (vết bẩn, vết cháy) và đánh giá OK/NG.

import numpy as np


def cat_roi(anh, roi):
    """Cắt ROI (x, y, w, h); báo lỗi nếu ROI nằm ngoài ảnh."""
    x, y, w, h = roi
    cao, rong = anh.shape[:2]
    x1, y1 = max(0, x), max(0, y)
    x2, y2 = min(rong, x + w), min(cao, y + h)
    if x1 >= x2 or y1 >= y2:
        raise ValueError(f"ROI {roi} nằm ngoài ảnh {rong}x{cao}")
    return anh[y1:y2, x1:x2]


def ti_le_pixel_toi(anh_xam, nguong=50):
    """Tỉ lệ (0..1) số pixel tối hơn ngưỡng."""
    if anh_xam.ndim != 2:
        raise ValueError("Cần ảnh xám 2 chiều")
    return float(np.count_nonzero(anh_xam < nguong)) / anh_xam.size


def danh_gia(ti_le_toi, gioi_han=0.02):
    """OK nếu vết tối chiếm không quá giới hạn."""
    return "OK" if ti_le_toi <= gioi_han else "NG"

Hàm cat_roi giống bài cắt ROI, resize, xoay, lật. Ở đây ta không cần OpenCV: ảnh chỉ là mảng NumPy (xem ảnh là mảng số), nên có thể tự tạo ảnh thử trong test mà không cần file ảnh thật.

Viết test đầu tiên

Tạo file test_xu_ly.py cùng thư mục:

import numpy as np
import pytest

from xu_ly import cat_roi, danh_gia, ti_le_pixel_toi


@pytest.fixture
def anh_trang():
    """Ảnh xám 100x200 toàn màu sáng (200)."""
    return np.full((100, 200), 200, np.uint8)


def test_cat_roi_dung_kich_thuoc(anh_trang):
    vung = cat_roi(anh_trang, (10, 20, 50, 30))
    assert vung.shape == (30, 50)          # (cao, rộng)


def test_cat_roi_bi_cat_o_bien(anh_trang):
    vung = cat_roi(anh_trang, (180, 90, 50, 50))
    assert vung.shape == (10, 20)


def test_cat_roi_ngoai_anh_bao_loi(anh_trang):
    with pytest.raises(ValueError, match="nằm ngoài ảnh"):
        cat_roi(anh_trang, (500, 500, 10, 10))


def test_anh_sach_khong_co_pixel_toi(anh_trang):
    assert ti_le_pixel_toi(anh_trang) == 0.0


def test_vet_toi_100_pixel(anh_trang):
    anh_trang[0:10, 0:10] = 0              # vết tối 10x10 = 100 pixel
    assert ti_le_pixel_toi(anh_trang) == pytest.approx(100 / 20000)


def test_anh_mau_bi_tu_choi():
    anh_mau = np.zeros((10, 10, 3), np.uint8)
    with pytest.raises(ValueError):
        ti_le_pixel_toi(anh_mau)


@pytest.mark.parametrize(
    "ti_le, ket_qua",
    [(0.0, "OK"), (0.02, "OK"), (0.0201, "NG"), (0.5, "NG")],
)
def test_danh_gia_theo_nguong(ti_le, ket_qua):
    assert danh_gia(ti_le) == ket_qua

Ba công cụ quan trọng nhất của pytest đều có mặt ở đây:

Công cụTác dụngTrong ví dụ
@pytest.fixtureTạo dữ liệu dùng chung; mỗi test nhận một bản mớianh_trang
pytest.raisesKhẳng định hàm phải báo lỗi (có thể kiểm tra cả nội dung thông báo)ROI ngoài ảnh, ảnh màu
@pytest.mark.parametrizeChạy một test với nhiều bộ dữ liệu4 mức tỉ lệ quanh ngưỡng
pytest.approxSo sánh số thực có sai số làm trònTỉ lệ 0,005
Fixture tạo bản mới cho từng testTest test_vet_toi_100_pixel vẽ vết đen lên anh_trang. Vì fixture được gọi lại cho mỗi test, các test khác vẫn nhận ảnh sạch. Nếu bạn dùng một biến toàn cục thay cho fixture, thứ tự chạy test sẽ ảnh hưởng kết quả, một loại lỗi rất khó tìm.

Chạy test

pytest -v
test_xu_ly.py::test_cat_roi_dung_kich_thuoc PASSED                       [ 10%]
test_xu_ly.py::test_cat_roi_bi_cat_o_bien PASSED                         [ 20%]
test_xu_ly.py::test_cat_roi_ngoai_anh_bao_loi PASSED                     [ 30%]
test_xu_ly.py::test_anh_sach_khong_co_pixel_toi PASSED                   [ 40%]
test_xu_ly.py::test_vet_toi_100_pixel PASSED                             [ 50%]
test_xu_ly.py::test_anh_mau_bi_tu_choi PASSED                            [ 60%]
test_xu_ly.py::test_danh_gia_theo_nguong[0.0-OK] PASSED                  [ 70%]
test_xu_ly.py::test_danh_gia_theo_nguong[0.02-OK] PASSED                 [ 80%]
test_xu_ly.py::test_danh_gia_theo_nguong[0.0201-NG] PASSED               [ 90%]
test_xu_ly.py::test_danh_gia_theo_nguong[0.5-NG] PASSED                  [100%]
============================== 10 passed in 0.05s ==============================

7 hàm test nhưng có 10 kết quả, vì test dùng parametrize được tách thành 4 lần chạy, mỗi lần có tên riêng như [0.02-OK]. Thời gian chạy trên máy bạn sẽ khác một chút.

Khi test bắt được lỗi

Giả sử một đồng nghiệp "tối ưu" hàm đánh giá và vô tình đổi <= thành <:

def danh_gia(ti_le_toi, gioi_han=0.02):
    return "OK" if ti_le_toi < gioi_han else "NG"     # lỗi: đổi <= thành <

Chạy pytest -q, kết quả (rút gọn phần cuối):

    def test_danh_gia_theo_nguong(ti_le, ket_qua):
>       assert danh_gia(ti_le) == ket_qua
E       AssertionError: assert 'NG' == 'OK'
E
E         - OK
E         + NG

test_xu_ly.py:48: AssertionError
=========================== short test summary info ============================
FAILED test_xu_ly.py::test_danh_gia_theo_nguong[0.02-OK] - AssertionError: as...
1 failed, 9 passed in 0.07s

pytest chỉ rõ: đúng ở mức 0,02, hàm trả về NG trong khi quy định là OK. Đây chính là lỗi "loại nhầm hàng ở ranh giới" ở đầu bài, bị chặn trước khi lên máy trạm.

Các lệnh hay dùng

LệnhÝ nghĩa
pytest -qKết quả gọn
pytest -vLiệt kê từng test
pytest -k roiChỉ chạy test có chữ "roi" trong tên
pytest -xDừng ngay ở test lỗi đầu tiên
pytest --lfChỉ chạy lại các test lỗi ở lần trước

Nên test gì trong code xử lý ảnh?

  1. Hình dạng và kiểu dữ liệu đầu ra: shape, dtype. Rất nhiều lỗi đến từ nhầm (cao, rộng) với (rộng, cao).
  2. Ảnh tổng hợp có đáp án biết trước: vẽ một hình tròn đặc bán kính 20 rồi kiểm tra số pixel trắng xấp xỉ π·20² ≈ 1257. Lưu ý cv2.contourArea đo theo đường viền nên cho số nhỏ hơn (khoảng 1200), vì vậy hãy ghi rõ test đo theo cách nào. Không cần ảnh thật.
  3. Đầu vào sai: ảnh rỗng, ảnh màu khi cần ảnh xám, ROI ngoài biên. Hàm nên báo lỗi rõ ràng thay vì trả kết quả vô nghĩa.
  4. Giá trị ngưỡng: đúng tại ngưỡng, ngay trên và ngay dưới ngưỡng.
  5. Ảnh hồi quy (regression): giữ vài ảnh thật từng gây lỗi trong thư mục tests/data/ kèm kết quả mong đợi. Mỗi lần sửa thuật toán, chạy lại để chắc không "sửa chỗ này hỏng chỗ kia".
Đừng so sánh số thực bằng ==Kết quả như độ tròn, góc, kích thước mm gần như luôn có sai số làm tròn. Dùng pytest.approx(gia_tri, abs=0.01) hoặc rel=1e-3 tùy độ chính xác bạn cần.

Tổ chức thư mục gợi ý

du_an/
├── tram_kiem_tra/
│   ├── __init__.py
│   └── xu_ly.py
├── tests/
│   ├── data/            # ảnh hồi quy nhỏ
│   └── test_xu_ly.py
└── pyproject.toml

Kết hợp với Git: tập thói quen chạy pytest trước mỗi lần commit. Khi dự án lớn hơn, bạn có thể cho máy chủ CI (ví dụ GitHub Actions) tự chạy test mỗi lần đẩy code.

Bài tập

Viết hàm dem_vat(anh_nhi_phan) đếm số vùng trắng (dùng cv2.connectedComponents, nhớ trừ nền). Viết test: ảnh đen trả về 0; ảnh có 3 hình vuông tách rời trả về 3; hai hình vuông chạm nhau trả về 1.

Gợi ý

Tạo ảnh bằng np.zeros((100, 100), np.uint8) rồi gán anh[10:20, 10:20] = 255. cv2.connectedComponents trả về (so_nhan, nhan), trong đó nhãn 0 là nền nên số vật là so_nhan - 1.

Nguồn tham khảo: pytest: assert và pytest.raises, pytest: parametrize, pytest: fixtures.

Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.