⭐ Vision Engineer · Chặng 2Bài 4/6Bài học~45 phút

OpenCV: vẽ kết quả kiểm tra lên ảnh (khung, chữ, overlay)

Vẽ khung, đường, điểm, chữ có viền, bảng trong suốt bằng addWeighted và thanh trạng thái OK/NG; lưu ý tiếng Việt với putText.

OpenCV: vẽ kết quả kiểm tra lên ảnh (khung, chữ, overlay)

Học xong bài này, bạn sẽ

  • Vẽ đường, khung, tròn, chữ
  • Hiển thị kết quả OK/NG dễ đọc
  • Tô vùng bán trong suốt

Nên học trước: ROI, resize, xoay, lật và ghép ảnh

Nội dung bài viết
  1. 1. Hệ tọa độ và quy ước màu
  2. 2. Các hàm vẽ cơ bản
  3. 3. Viết chữ
  4. 4. Vùng tô bán trong suốt
  5. 5. Bố cục ảnh kết quả chuyên nghiệp
  6. 6. Kích thước nét vẽ theo độ phân giải
  7. Tóm tắt
  8. Bài tập

Kết quả kiểm tra phải được nhìn thấy: người vận hành cần biết ngay sản phẩm OK hay NG, lỗi nằm ở đâu, đo được bao nhiêu. Kỹ sư cần ảnh kết quả để gỡ lỗi thuật toán và để làm bằng chứng khi khách hàng hỏi. Bài này dạy các hàm vẽ của OpenCV và cách trình bày kết quả rõ ràng, chuyên nghiệp.

1. Hệ tọa độ và quy ước màu

  • Hàm vẽ nhận điểm dạng (x, y) (cột, hàng), gốc ở góc trái trên, y tăng xuống dưới. Ngược với slicing anh[y, x].
  • Tọa độ phải là số nguyên (int). Kết quả tính toán thường là số thực, phải ép int() hoặc round(), nếu không OpenCV báo lỗi.
  • Màu theo BGR: (0, 255, 0) xanh lá, (0, 0, 255) đỏ.
  • Hàm vẽ sửa trực tiếp lên ảnh. Muốn giữ ảnh gốc, vẽ lên bản sao anh.copy().

2. Các hàm vẽ cơ bản

import cv2
import numpy as np

anh = np.full((360, 640, 3), 40, np.uint8)
XANH, DO, VANG, TRANG = (0, 200, 0), (0, 0, 255), (0, 220, 255), (255, 255, 255)

cv2.line(anh, (20, 30), (620, 30), TRANG, 1)                       # đường thẳng
cv2.rectangle(anh, (40, 60), (200, 200), XANH, 2)                  # khung, độ dày 2
cv2.rectangle(anh, (240, 60), (400, 200), DO, -1)                  # -1: tô kín
cv2.circle(anh, (520, 130), 60, VANG, 2, cv2.LINE_AA)              # LINE_AA: khử răng cưa
cv2.ellipse(anh, (120, 290), (80, 40), 30, 0, 360, XANH, 2)        # elip xoay 30°
pts = np.array([[260, 250], [400, 240], [380, 330], [280, 320]], np.int32)
cv2.polylines(anh, [pts], True, VANG, 2)                           # đa giác khép kín
cv2.drawMarker(anh, (520, 290), DO, cv2.MARKER_CROSS, 30, 2)       # dấu chữ thập
cv2.imwrite("cac_hinh.png", anh)

Tham số chung: ảnh, tọa độ, màu, độ dày (-1 là tô kín), kiểu đường (cv2.LINE_AA cho đường mịn khi vẽ đường tròn, đường xiên).

3. Viết chữ

anh = np.full((200, 640, 3), 40, np.uint8)
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(anh, "OK 12.48 mm", (20, 60), font, 1.2, XANH, 2, cv2.LINE_AA)

# Đo kích thước chữ để vẽ nền phía sau cho dễ đọc
chu = "NG: xuoc be mat"
(rong_chu, cao_chu), duoi = cv2.getTextSize(chu, font, 0.9, 2)
x, y = 20, 140
cv2.rectangle(anh, (x - 6, y - cao_chu - 8), (x + rong_chu + 6, y + duoi + 4), DO, -1)
cv2.putText(anh, chu, (x, y), font, 0.9, TRANG, 2, cv2.LINE_AA)
cv2.imwrite("chu.png", anh)
print(rong_chu, cao_chu, duoi)   # rộng, cao (pixel) và phần chữ thò xuống dưới đường chân

Con số in ra phụ thuộc phiên bản OpenCV (OpenCV 5 vẽ chữ khác OpenCV 4), vì vậy đừng gõ cứng kích thước chữ trong code mà luôn đo bằng getTextSize.

Tọa độ của putText là góc trái dưới của dòng chữ (đường chân chữ), không phải góc trái trên như hình chữ nhật. getTextSize cho biết kích thước chữ để vẽ nền cho vừa.

Tiếng Việt có dấu: tùy phiên bản OpenCVOpenCV 5 vẽ chữ bằng bộ font mới, hiển thị được tiếng Việt có dấu. OpenCV 4.x (vẫn rất phổ biến trong nhà máy) chỉ có font ASCII: chữ "xước" sẽ thành dấu hỏi. Nếu chương trình có thể chạy trên máy cài OpenCV 4, hãy dùng chữ không dấu hoặc mã lỗi ngắn (NG_XUOC), hoặc vẽ chữ bằng Pillow như bài tập 3. Kiểm tra phiên bản bằng print(cv2.__version__).

4. Vùng tô bán trong suốt

Tô kín vùng lỗi sẽ che mất chi tiết. Tô bán trong suốt bằng cách vẽ lên bản sao rồi trộn:

def to_ban_trong(anh, mat_na, mau=(0, 0, 255), alpha=0.4):
    """Tô màu bán trong suốt lên các pixel thuộc mặt nạ."""
    lop_mau = anh.copy()
    lop_mau[mat_na > 0] = mau
    return cv2.addWeighted(lop_mau, alpha, anh, 1 - alpha, 0)


anh = np.full((200, 300, 3), 150, np.uint8)
mat_na = np.zeros((200, 300), np.uint8)
cv2.circle(mat_na, (150, 100), 50, 255, -1)        # vùng "lỗi"
ket_qua = to_ban_trong(anh, mat_na)
print(anh[100, 150], ket_qua[100, 150], ket_qua[10, 10])
[150 150 150] [ 90  90 192] [150 150 150]

cv2.addWeighted(a, alpha, b, beta, gamma) tính a·alpha + b·beta + gamma cho từng pixel. Pixel trong vùng lỗi thành pha trộn 40% đỏ + 60% ảnh gốc; pixel ngoài vùng giữ nguyên.

5. Bố cục ảnh kết quả chuyên nghiệp

Một ảnh kết quả tốt cho trạm kiểm tra gồm: khung ROI, kết quả từng vùng (xanh = đạt, đỏ = lỗi), giá trị đo, và một thanh trạng thái ghi kết quả tổng, mã sản phẩm, thời gian xử lý. Gói tất cả vào một hàm:

import time

MAU_KQ = {"OK": (0, 200, 0), "NG": (0, 0, 255)}


def ve_ket_qua(anh, cac_vung, ket_qua, ma_sp, thoi_gian_ms):
    """cac_vung: list dict {"hop": (x, y, w, h), "kq": "OK"/"NG", "nhan": str}."""
    ve = anh.copy()
    for v in cac_vung:
        x, y, w, h = v["hop"]
        mau = MAU_KQ[v["kq"]]
        cv2.rectangle(ve, (x, y), (x + w, y + h), mau, 2)
        cv2.putText(ve, v["nhan"], (x, max(18, y - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.55, mau, 2, cv2.LINE_AA)

    # Thanh trạng thái phía trên
    cao_thanh = 44
    thanh = np.full((cao_thanh, ve.shape[1], 3), 30, np.uint8)
    cv2.rectangle(thanh, (0, 0), (110, cao_thanh), MAU_KQ[ket_qua], -1)
    cv2.putText(thanh, ket_qua, (22, 32), cv2.FONT_HERSHEY_SIMPLEX, 1.1, (255, 255, 255), 3, cv2.LINE_AA)
    thong_tin = f"{ma_sp}  |  {thoi_gian_ms:.0f} ms  |  {time.strftime('%Y-%m-%d %H:%M:%S')}"
    cv2.putText(thanh, thong_tin, (125, 29), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (230, 230, 230), 1, cv2.LINE_AA)
    return np.vstack([thanh, ve])


anh = np.full((300, 520, 3), 70, np.uint8)
cv2.rectangle(anh, (40, 60), (480, 260), (190, 190, 190), -1)
cac_vung = [
    {"hop": (60, 80, 120, 160), "kq": "OK", "nhan": "lo 1: 12.48"},
    {"hop": (200, 80, 120, 160), "kq": "NG", "nhan": "lo 2: 12.71"},
    {"hop": (340, 80, 120, 160), "kq": "OK", "nhan": "lo 3: 12.51"},
]
ket_qua = "NG" if any(v["kq"] == "NG" for v in cac_vung) else "OK"
ve = ve_ket_qua(anh, cac_vung, ket_qua, "SP-000128", 37.6)
cv2.imwrite("ket_qua.png", ve)
print(ve.shape, ket_qua)
(344, 520, 3) NG

Nguyên tắc trình bày đã áp dụng:

  • Kết quả tổng to, rõ, ở vị trí cố định: người vận hành nhìn lướt từ xa vẫn thấy.
  • Hai màu có nghĩa cố định: xanh lá = đạt, đỏ = lỗi. Đừng dùng đỏ để trang trí.
  • Vẽ lên bản sao, giữ nguyên ảnh gốc để lưu và phân tích lại sau.
  • Thanh thông tin nằm ngoài vùng ảnh (ghép thêm bằng np.vstack), không che chi tiết sản phẩm.
  • Luôn ghi thời gian xử lý: chỉ số đầu tiên khách hàng hỏi khi tốc độ dây chuyền tăng.

6. Kích thước nét vẽ theo độ phân giải

Ảnh camera 5 MP (2448×2048) khi thu nhỏ để hiển thị thì nét vẽ độ dày 2 gần như biến mất. Tính độ dày và cỡ chữ theo kích thước ảnh:

def thong_so_ve(anh):
    canh_ngan = min(anh.shape[:2])
    day = max(1, round(canh_ngan / 400))
    co_chu = canh_ngan / 800
    return day, co_chu


print(thong_so_ve(np.zeros((480, 640), np.uint8)), thong_so_ve(np.zeros((2048, 2448), np.uint8)))
(1, 0.6) (5, 2.56)

Tóm tắt

  • Điểm vẽ là (x, y) số nguyên, màu BGR; hàm vẽ sửa trực tiếp ảnh, nên vẽ lên bản sao.
  • rectangle, circle, line, polylines, drawMarker; độ dày -1 là tô kín, LINE_AA cho nét mịn.
  • putText đặt theo góc trái dưới, không vẽ được tiếng Việt có dấu; getTextSize để vẽ nền chữ.
  • Vùng bán trong suốt: vẽ lên bản sao rồi addWeighted.
  • Ảnh kết quả: kết quả tổng rõ ràng, màu có nghĩa cố định, thông tin không che sản phẩm.

Bài tập

Bài 1. Thước đo trên ảnh

Vẽ một đường đo kích thước giữa hai điểm (100, 200) và (500, 200): đường thẳng hai đầu có vạch đứng, ở giữa ghi khoảng cách theo mm biết 1 pixel = 0,05 mm.

Xem đáp án
import cv2
import numpy as np

def ve_kich_thuoc(anh, p1, p2, mm_moi_px, mau=(0, 220, 255)):
    cv2.line(anh, p1, p2, mau, 2, cv2.LINE_AA)
    for (x, y) in (p1, p2):
        cv2.line(anh, (x, y - 12), (x, y + 12), mau, 2)
    dai_mm = np.hypot(p2[0] - p1[0], p2[1] - p1[1]) * mm_moi_px
    giua = ((p1[0] + p2[0]) // 2, (p1[1] + p2[1]) // 2 - 12)
    chu = f"{dai_mm:.2f} mm"
    (w, _), _ = cv2.getTextSize(chu, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
    cv2.putText(anh, chu, (giua[0] - w // 2, giua[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.7, mau, 2, cv2.LINE_AA)
    return dai_mm


anh = np.full((300, 600, 3), 40, np.uint8)
print(ve_kich_thuoc(anh, (100, 200), (500, 200), 0.05))
20.0

Bài 2. Lưới ô có kết quả

Chia ảnh 600×400 thành 3×2 ô. Ngẫu nhiên (seed 1) đánh một ô là NG. Vẽ khung từng ô màu theo kết quả và tô bán trong suốt ô NG.

Gợi ý

Dùng hai vòng lặp như bài ROI để có tọa độ ô; tạo mặt nạ cho ô NG bằng cv2.rectangle(mat_na, ..., 255, -1) rồi gọi to_ban_trong. Vẽ khung các ô sau khi tô để viền không bị mờ.

Bài 3. Chữ tiếng Việt bằng Pillow (cho OpenCV 4.x)

Viết hàm viet_chu_viet(anh_bgr, chu, xy, co_chu, mau_bgr) dùng Pillow (PIL.ImageDraw, ImageFont.truetype) để vẽ chữ có dấu lên ảnh OpenCV. Nhớ đổi BGR ↔ RGB.

Xem đáp án
# (minh họa) cần file font .ttf có tiếng Việt, ví dụ Arial trên Windows: C:/Windows/Fonts/arial.ttf
from PIL import Image, ImageDraw, ImageFont

def viet_chu_viet(anh_bgr, chu, xy, co_chu=24, mau_bgr=(255, 255, 255), font_path="C:/Windows/Fonts/arial.ttf"):
    anh_pil = Image.fromarray(cv2.cvtColor(anh_bgr, cv2.COLOR_BGR2RGB))
    ve = ImageDraw.Draw(anh_pil)
    font = ImageFont.truetype(font_path, co_chu)
    ve.text(xy, chu, font=font, fill=mau_bgr[::-1])          # Pillow dùng RGB
    return cv2.cvtColor(np.array(anh_pil), cv2.COLOR_RGB2BGR)

Cách này chậm hơn putText vài lần, nên chỉ dùng cho ảnh báo cáo, không dùng trong vòng lặp camera tốc độ cao.

Bài tiếp theo
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í.