⭐ Vision Engineer · Chặng 8Bài 1/6Bài học~60 phút

Quy đổi pixel sang mm bằng vật chuẩn và đánh giá sai số đo

Hiệu chuẩn tỉ lệ mm/pixel bằng tấm lưới chấm và căn mẫu, đo lặp lại để tính độ lặp lại, bias, tỉ số năng lực, và biết khi nào cần hiệu chuẩn đầy đủ.

Quy đổi pixel sang mm bằng vật chuẩn và đánh giá sai số đo

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

  • Hiệu chuẩn tỉ lệ bằng vật chuẩn
  • Đánh giá độ lặp lại và sai số
  • Biết khi nào quy đổi đơn giản là đủ

Nên học trước: Dự án chặng 7: đọc và xác minh số seri

Nội dung bài viết
  1. 1. Quy đổi đơn giản là gì, và nó giả định điều gì?
  2. 2. Chọn vật chuẩn
  3. 3. Dữ liệu mô phỏng
  4. 4. Hiệu chuẩn tỉ lệ bằng tấm lưới chấm
  5. 5. Sai lệch biên và đo chi tiết
  6. 6. Độ lặp lại, sai lệch và năng lực đo
  7. 7. Khi nào quy đổi đơn giản không còn đủ?
  8. 8. Những lỗi thường gặp
  9. 9. Bài tập
  10. Tóm tắt

Ở chặng 7, bài Đo cạnh subpixel, fitting đường thẳng và đường tròn đã cho bạn vị trí cạnh chính xác tới một phần nhỏ của pixel. Nhưng bản vẽ ghi mm, PLC cần mm, và câu hỏi của trưởng ca luôn là "máy đo có tin được không?". Chặng Calibration bắt đầu bằng cách đơn giản nhất và cũng được dùng nhiều nhất trong nhà máy: quy đổi pixel sang mm bằng một hệ số tỉ lệ, đo trên vật chuẩn. Bài này dạy cách làm đúng, cách chứng minh bằng số rằng hệ đo đủ tốt so với dung sai (độ lặp lại, sai lệch), và quan trọng nhất là biết khi nào cách đơn giản này không còn đủ để chuyển sang hiệu chuẩn đầy đủ ở các bài sau.

Mục tiêu cuối bàiTừ ảnh tấm chuẩn lưới chấm và căn mẫu, tính được hệ số mm/pixel theo x và y, sai lệch biên cần bù; đo 30 lần một chi tiết, tính trung bình, độ lệch chuẩn, ±3σ, bias, rồi kết luận hệ đo có dùng được cho dung sai ±0,05 mm hay không.

1. Quy đổi đơn giản là gì, và nó giả định điều gì?

kích thước (mm) = kích thước (pixel) × s        s: hệ số tỉ lệ, đơn vị mm/pixel

Nếu tỉ lệ hai chiều khác nhau:
  Δx (mm) = Δu (px) × sx        Δy (mm) = Δv (px) × sy

Công thức chỉ có một dòng nhưng đứng trên bốn giả định. Ghi nhớ chúng, vì mỗi lần một giả định bị vi phạm là một nguồn sai số:

Giả địnhBị vi phạm khiHậu quả
Camera nhìn vuông góc với mặt phẳng đoGiá camera lệch, cố tình đặt nghiêngTỉ lệ thay đổi dần theo vị trí trong ảnh; sx ≠ sy
Ống kính không méoỐng kính tiêu cự ngắn, góc rộngTỉ lệ ở mép ảnh khác ở tâm ảnh
Mọi đặc trưng nằm trên cùng một mặt phẳng, đúng mặt phẳng hiệu chuẩnVật lên xuống, vật dày, đo mặt trên nhưng hiệu chuẩn ở mặt đáyĐổi tỉ lệ ΔZ/Z (bài Ống kính telecentric: khi nào cần?)
Vị trí cạnh tìm được trùng với cạnh thậtĐèn nền khuếch tán, ngưỡng cắt khác 50%, ba-viaSai lệch biên cố định (bias), trái dấu cho kích thước ngoài và kích thước trong

Vì sao lại có trường hợp sx ≠ sy? Camera area scan có pixel vuông nên sx = sy nếu camera vuông góc. Hai trường hợp hay gặp: camera nghiêng quanh một trục (chiều bị nghiêng "co" lại theo cos của góc nghiêng), và camera line scan, nơi sx do ống kính quyết định còn sy do tốc độ băng tải và encoder quyết định (bài tập 3). Hiệu chuẩn hai hệ số riêng không tốn thêm công sức, nên hãy luôn làm.

2. Chọn vật chuẩn

Vật chuẩnĐo được gìGhi chú
Tấm lưới chấm tròn (circle grid) hoặc bàn cờ trên kính / gốmHệ số tỉ lệ sx, sy từ khoảng cách tâm; phần dư cho biết méo/nghiêngCó giấy chứng nhận bước lưới. Dùng được với đèn nền (chấm crôm trên kính) hoặc đèn chiếu mặt trên
Căn mẫu (gauge block)Kích thước cạnh tới cạnh đã biết → sai lệch biên của cả hệRất chính xác, rẻ, có sẵn trong phòng QC. Đặt đúng mặt phẳng đo
Thước kính vạch (stage micrometer)Tỉ lệ theo một chiềuTiện cho FOV nhỏ
Chốt chuẩn, vòng chuẩn (pin / ring gauge)Bù biên cho đường kính ngoài và đường kính lỗDùng khi đo đường kính là chính
Chi tiết mẫu đã đo bằng CMM (golden sample)Kiểm tra toàn chuỗi trên chính sản phẩmDùng cho kiểm tra đầu ca (bài Kiểm tra hiệu chuẩn định kỳ)

Quy tắc kinh nghiệm: sai số của vật chuẩn nên nhỏ hơn ít nhất khoảng 10 lần (tối thiểu khoảng 4 lần) sai số bạn muốn kiểm chứng. Thước kẹp in trên giấy A4 không phải vật chuẩn.

Hiệu chuẩn trong đúng điều kiện đoVật chuẩn phải nằm đúng mặt phẳng của đặc trưng cần đo (cùng độ cao), chụp với cùng đèn, cùng khẩu độ, cùng lấy nét, cùng thuật toán tìm cạnh. Đổi bất kỳ thứ gì trong số đó là phải hiệu chuẩn lại.

3. Dữ liệu mô phỏng

Để có số liệu kiểm chứng được, ta mô phỏng một camera nhìn xuống đèn nền. Script vẽ vật với độ chính xác dưới pixel (siêu lấy mẫu 8 × 8), làm mờ như ống kính thật, thêm nhiễu cảm biến. Ba "sự thật" được giấu trong mô phỏng mà phần mềm đo không biết, và bạn sẽ phải tìm ra:

  • Tỉ lệ thật sx = 0,02500 mm/px, sy = 0,02510 mm/px (camera hơi nghiêng).
  • Đèn nền khuếch tán làm biên vật tối bị "ăn" vào 4 µm mỗi phía.
  • Chi tiết trên băng tải lên xuống ±0,1 mm ở khoảng cách 200 mm.
import os

import cv2
import numpy as np

SX, SY = 0.02500, 0.02510       # mm/pixel THẬT (phần mềm không biết): y lớn hơn 0,4% do camera hơi nghiêng
W, H = 640, 480
NEN, VAT = 220.0, 30.0          # mức xám nền sáng (backlight) và vật tối
AN_BIEN = 0.004                 # đèn nền khuếch tán "ăn" vào biên vật tối 4 µm mỗi phía
K = 8                           # siêu lấy mẫu 8×8 điểm mỗi pixel → biên chính xác dưới pixel


def ve(hinh, x0, y0, rng, ti_le=1.0):
    """hinh: danh sách ('tron', cx, cy, r) hoặc ('chu_nhat', x1, y1, x2, y2), đơn vị mm.
    (x0, y0): tọa độ mm tại pixel (0, 0); ti_le: hệ số đổi tỉ lệ khi vật lên/xuống."""
    sx, sy = SX * ti_le, SY * ti_le
    phu = np.zeros((H, W), np.float32)                      # tỉ lệ diện tích pixel bị vật che
    o = (np.arange(K) + 0.5) / K - 0.5                      # vị trí mẫu con quanh tâm pixel
    for h in hinh:
        if h[0] == "tron":
            _, cx, cy, r = h
            r -= AN_BIEN
            bx1, by1, bx2, by2 = cx - r, cy - r, cx + r, cy + r
        else:
            bx1, by1, bx2, by2 = h[1] + AN_BIEN, h[2] + AN_BIEN, h[3] - AN_BIEN, h[4] - AN_BIEN
        u1, u2 = int((bx1 - x0) / sx) - 2, int((bx2 - x0) / sx) + 3
        v1, v2 = int((by1 - y0) / sy) - 2, int((by2 - y0) / sy) + 3
        uu = (np.arange(u1, u2)[:, None] + o[None, :]).ravel()
        vv = (np.arange(v1, v2)[:, None] + o[None, :]).ravel()
        X, Y = np.meshgrid(x0 + uu * sx, y0 + vv * sy)
        if h[0] == "tron":
            trong = (X - cx) ** 2 + (Y - cy) ** 2 <= r * r
        else:
            trong = (X >= bx1) & (X <= bx2) & (Y >= by1) & (Y <= by2)
        cov = trong.reshape(v2 - v1, K, u2 - u1, K).mean(axis=(1, 3))
        phu[v1:v2, u1:u2] = np.maximum(phu[v1:v2, u1:u2], cov)
    anh = NEN - (NEN - VAT) * phu
    anh = cv2.GaussianBlur(anh, (0, 0), 0.8)                # ống kính không nét tuyệt đối
    anh += rng.normal(0, 2.0, anh.shape)                    # nhiễu cảm biến
    return np.clip(np.round(anh), 0, 255).astype(np.uint8)


rng = np.random.default_rng(7)
for d in ("tam_chuan", "can_mau", "chi_tiet"):
    os.makedirs(d, exist_ok=True)

# 1) Tấm chuẩn lưới chấm tròn 5 × 4, bước 2,500 mm, chấm Ø1,250 mm; vị trí chấm sai cỡ 0,5 µm
sai = rng.normal(0, 0.0005, (20, 2))
luoi = [("tron", i * 2.5 + sai[5 * j + i, 0], j * 2.5 + sai[5 * j + i, 1], 0.625)
        for j in range(4) for i in range(5)]
for k in range(10):
    lx, ly = rng.uniform(-0.2, 0.2, 2)
    cv2.imwrite(f"tam_chuan/tc_{k:02d}.png", ve(luoi, -3.0 + lx, -2.25 + ly, rng))

# 2) Căn mẫu (gauge block) rộng 10,000 mm, đặt đúng mặt phẳng đo
for k in range(10):
    lx, ly = rng.uniform(-0.2, 0.2, 2)
    cv2.imwrite(f"can_mau/cm_{k:02d}.png", ve([("chu_nhat", 0, 0, 10.0, 4.0)], -3.0 + lx, -4.0 + ly, rng))

# 3) Chi tiết THẬT 8,012 × 5,000 mm (bản vẽ 8,00 ± 0,05 và 5,00 ± 0,05), chụp 30 lần trên băng tải:
#    vị trí lệch ±0,3 mm, chiều cao lên xuống ±0,1 mm ở Z = 200 mm
for k in range(30):
    lx, ly = rng.uniform(-0.3, 0.3, 2)
    dz = rng.uniform(-0.1, 0.1)
    cv2.imwrite(f"chi_tiet/ct_{k:02d}.png",
                ve([("chu_nhat", 0, 0, 8.012, 5.000)], -4.0 + lx, -3.5 + ly, rng, ti_le=(200 + dz) / 200))
print("Đã tạo 10 ảnh tấm chuẩn, 10 ảnh căn mẫu, 30 ảnh chi tiết")
Đã tạo 10 ảnh tấm chuẩn, 10 ảnh căn mẫu, 30 ảnh chi tiết

4. Hiệu chuẩn tỉ lệ bằng tấm lưới chấm

Tâm của một chấm tròn là đặc trưng rất ổn định: nó không phụ thuộc ngưỡng cắt hay việc biên bị ăn vào đều các phía. Vì vậy hệ số tỉ lệ nên lấy từ khoảng cách tâm, không lấy từ đường kính chấm. Tâm được tính bằng trọng tâm có trọng số theo độ tối (subpixel), sau đó fit một phép biến đổi affine từ tọa độ lưới (mm) sang tọa độ ảnh (pixel) bằng bình phương tối thiểu. Fit affine cho phép tấm chuẩn đặt hơi xoay, và cho ra hai hệ số sx, sy riêng.

import glob
import json

import cv2
import numpy as np

BUOC, COT, HANG = 2.5, 5, 4          # bước lưới (mm) và số chấm theo giấy chứng nhận tấm chuẩn


def tam_cac_cham(anh):
    """Tâm subpixel của các chấm tối: trọng tâm có trọng số theo độ tối trong vùng quanh mỗi chấm."""
    _, nhi_phan = cv2.threshold(anh, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    cs, _ = cv2.findContours(nhi_phan, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    nen = float(np.median(anh))
    vat = float(np.percentile(anh, 1))
    tam = []
    for c in cs:
        if cv2.contourArea(c) < 50:
            continue
        x, y, w, h = cv2.boundingRect(c)
        x, y = x - 4, y - 4
        roi = anh[y:y + h + 8, x:x + w + 8].astype(np.float64)
        trong_so = np.clip((nen - roi) / (nen - vat), 0, 1)       # 0 ở nền, 1 trong chấm
        yy, xx = np.mgrid[0:roi.shape[0], 0:roi.shape[1]]
        tam.append((x + (trong_so * xx).sum() / trong_so.sum(), y + (trong_so * yy).sum() / trong_so.sum()))
    tam = sorted(tam, key=lambda p: p[1])                              # xếp theo hàng rồi theo cột
    hang = [sorted(tam[i * COT:(i + 1) * COT]) for i in range(HANG)]
    return np.array([p for h in hang for p in h])


def hieu_chuan(anh):
    uv = tam_cac_cham(anh)
    the_gioi = np.array([(i * BUOC, j * BUOC) for j in range(HANG) for i in range(COT)])
    A = np.column_stack([the_gioi, np.ones(len(the_gioi))])
    he_so, *_ = np.linalg.lstsq(A, uv, rcond=None)                     # u, v = a·X + b·Y + c
    sx = 1 / np.hypot(he_so[0, 0], he_so[0, 1])                        # mm/pixel theo x
    sy = 1 / np.hypot(he_so[1, 0], he_so[1, 1])                        # mm/pixel theo y
    du = (uv - A @ he_so) * [sx, sy]                                   # phần dư quy ra mm
    return sx, sy, np.sqrt((du ** 2).sum(axis=1)).max()


files = sorted(glob.glob("tam_chuan/*.png"))
sx, sy, du_max = hieu_chuan(cv2.imread(files[0], cv2.IMREAD_GRAYSCALE))
print(f"Ảnh 1: sx = {1000 * sx:.4f} µm/px, sy = {1000 * sy:.4f} µm/px, phần dư lớn nhất {1000 * du_max:.2f} µm")

ket_qua = np.array([hieu_chuan(cv2.imread(f, cv2.IMREAD_GRAYSCALE))[:2] for f in files])
tb, ds = ket_qua.mean(axis=0), ket_qua.std(axis=0, ddof=1)
print(f"10 ảnh: sx = {1000 * tb[0]:.4f} ± {1000 * ds[0]:.4f} µm/px, sy = {1000 * tb[1]:.4f} ± {1000 * ds[1]:.4f} µm/px")
print(f"sy/sx = {tb[1] / tb[0]:.4f} → chênh {100 * (tb[1] / tb[0] - 1):.2f}%")
json.dump({"sx": tb[0], "sy": tb[1]}, open("ti_le.json", "w"))
Ảnh 1: sx = 24.9998 µm/px, sy = 25.0998 µm/px, phần dư lớn nhất 1.03 µm
10 ảnh: sx = 25.0002 ± 0.0002 µm/px, sy = 25.1000 ± 0.0001 µm/px
sy/sx = 1.0040 → chênh 0.40%
  • Phần mềm tìm lại đúng sx = 25,000 µm/px và sy = 25,100 µm/px: chênh 0,40%. Dùng một hệ số chung cho cả hai chiều thì chiều cao 5 mm sẽ sai 20 µm, tức 20% dải dung sai ±0,05 mm (mục 5 kiểm chứng điều này).
  • Phần dư lớn nhất khoảng 1 µm là sai số chế tạo của chính tấm chuẩn: lưới thẳng, không có dấu hiệu méo. Với camera thật, đây là con số đầu tiên cần nhìn: phần dư lớn và có quy luật (lớn dần ra mép ảnh) nghĩa là ống kính méo hoặc phối cảnh, quy đổi đơn giản sẽ không đủ (mục 7).
  • Độ lệch chuẩn của hệ số qua 10 ảnh rất nhỏ (cỡ 0,0002 µm/px), vì 20 chấm, mỗi chấm hàng nghìn pixel, tự trung bình hóa nhiễu. Với chi tiết 8 mm (320 pixel), sai số do hệ số gây ra chỉ cỡ 320 × 0,0002 µm ≈ 0,06 µm, không đáng kể.
  • Thực tế nên dùng tấm chuẩn phủ khoảng 70–80% trường nhìn và chụp ở vài vị trí, để phần dư phản ánh được cả vùng mép ảnh.

5. Sai lệch biên và đo chi tiết

Hệ số tỉ lệ lấy từ tâm chấm thì không thấy được sai lệch biên. Nhưng kích thước chi tiết là khoảng cách cạnh tới cạnh, nên mọi hiệu ứng làm cạnh dịch chuyển (đèn nền khuếch tán, ngưỡng, độ mờ không đối xứng) đều đi thẳng vào kết quả. Cách xử lý chuẩn: đo một căn mẫu có kích thước biết trước bằng chính thuật toán đo chi tiết, lấy chênh lệch làm giá trị bù.

Thuật toán đo cạnh: với mỗi hàng (hoặc cột) quét ngang vật, tìm nơi profile độ sáng cắt mức 50% giữa nền và vật, nội suy tuyến tính giữa hai pixel để có vị trí subpixel, rồi lấy trung bình nhiều đường quét ở nửa giữa vật (tránh góc).

import csv
import glob
import json

import cv2
import numpy as np


def canh_subpixel(prof, muc):
    """Vị trí subpixel nơi profile cắt mức `muc` lần đầu (cạnh trái/trên) và lần cuối (cạnh phải/dưới)."""
    duoi = prof < muc
    i = int(np.argmax(duoi))                         # pixel tối đầu tiên
    j = len(prof) - 1 - int(np.argmax(duoi[::-1]))   # pixel tối cuối cùng
    dau = i - 1 + (prof[i - 1] - muc) / (prof[i - 1] - prof[i])
    cuoi = j + (prof[j] - muc) / (prof[j] - prof[j + 1])
    return dau, cuoi


def do_vat(anh):
    """Bề rộng (theo x) và chiều cao (theo y) của vật tối, pixel, trung bình nhiều đường quét."""
    a = anh.astype(np.float64)
    _, nhi_phan = cv2.threshold(anh, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    x, y, w, h = cv2.boundingRect(cv2.findNonZero(nhi_phan))
    muc = (np.median(a[nhi_phan == 0]) + np.median(a[nhi_phan > 0])) / 2   # mức 50% nền/vật
    rong = [np.subtract(*canh_subpixel(a[r], muc)[::-1]) for r in range(y + h // 4, y + 3 * h // 4)]
    cao = [np.subtract(*canh_subpixel(a[:, c], muc)[::-1]) for c in range(x + w // 4, x + 3 * w // 4)]
    return np.mean(rong), np.mean(cao)


tl = json.load(open("ti_le.json"))

# Bước 1: đo căn mẫu 10,000 mm để tìm sai lệch biên (bias) của cả chuỗi đèn + ống kính + thuật toán
do_cm = [do_vat(cv2.imread(f, cv2.IMREAD_GRAYSCALE))[0] * tl["sx"] for f in sorted(glob.glob("can_mau/*.png"))]
bu = 10.000 - np.mean(do_cm)
print(f"Căn mẫu 10,000 mm đo được {np.mean(do_cm):.4f} mm → bù biên {bu:+.4f} mm cho kích thước ngoài")
tl["bu_bien"] = bu
json.dump(tl, open("ti_le.json", "w"))

# Bước 2: đo 30 ảnh chi tiết, ghi cả kết quả thô và kết quả đã bù
with open("ket_qua_lap.csv", "w", newline="") as f:
    ghi = csv.writer(f)
    ghi.writerow(["anh", "rong_tho", "cao_tho", "rong", "cao"])
    for k, fn in enumerate(sorted(glob.glob("chi_tiet/*.png"))):
        rong_px, cao_px = do_vat(cv2.imread(fn, cv2.IMREAD_GRAYSCALE))
        rong, cao = rong_px * tl["sx"], cao_px * tl["sy"]
        ghi.writerow([fn, rong, cao, rong + bu, cao + bu])
        if k == 0:
            print(f"Ảnh đầu: rộng {rong_px:.3f} px → {rong:.4f} mm, bù → {rong + bu:.4f} mm")
            print(f"         cao  {cao_px:.3f} px → {cao:.4f} mm, bù → {cao + bu:.4f} mm"
                  f" (dùng nhầm sx: {cao_px * tl['sx'] + bu:.4f} mm)")
print("Đã ghi ket_qua_lap.csv")
Căn mẫu 10,000 mm đo được 9.9913 mm → bù biên +0.0087 mm cho kích thước ngoài
Ảnh đầu: rộng 320.196 px → 8.0049 mm, bù → 8.0136 mm
         cao  198.900 px → 4.9924 mm, bù → 5.0011 mm (dùng nhầm sx: 4.9812 mm)
Đã ghi ket_qua_lap.csv
  • Căn mẫu 10,000 mm đo ra 9,9913 mm: hệ đo "thấy" vật nhỏ hơn thật khoảng 8,7 µm (4 µm mỗi phía do đèn, phần còn lại do mờ và lấy mẫu). Đây là sai số hệ thống, lặp lại được nên bù được.
  • Bù +0,0087 mm áp dụng cho kích thước ngoài (bề rộng vật). Với kích thước trong (đường kính lỗ), biên dịch theo chiều ngược lại nên dấu bù ngược lại: hãy xác định riêng bằng vòng chuẩn. Khoảng cách tâm tới tâm thì không cần bù.
  • Dùng nhầm sx cho chiều cao: 4,9812 mm thay vì 5,0011 mm, sai gần 20 µm như đã dự đoán ở mục 4.

6. Độ lặp lại, sai lệch và năng lực đo

Một lần đo không nói lên gì. Phải đo cùng một chi tiết nhiều lần (ở đây 30 lần, mỗi lần chi tiết nằm ở vị trí và độ cao hơi khác, như trên băng tải thật) rồi tính:

Đại lượngCông thứcÝ nghĩa
Trung bìnhx̄ = Σxᵢ / nHệ đo "cho" giá trị nào
Độ lệch chuẩn σ√(Σ(xᵢ − x̄)² / (n − 1))Độ phân tán khi đo lặp lại
Độ lặp lại (repeatability)±3σ (dải 6σ)Khoảng chứa gần như mọi kết quả đo lặp lại (khoảng 99,7% nếu phân bố chuẩn)
Sai lệch (bias)x̄ − giá trị thậtSai số hệ thống; giá trị thật lấy từ CMM hoặc vật chuẩn
Tỉ số năng lực (đơn giản hóa)dải dung sai / [2 × (|bias| + 3σ)]Dải dung sai lớn gấp bao nhiêu lần dải sai số của hệ đo

Hướng dẫn thường dùng (quy tắc kinh nghiệm, không phải tiêu chuẩn bắt buộc): tỉ số ≥ 10:1 là tốt, có thể dùng thẳng cho quyết định OK/NG; từ 4:1 tới 10:1 chấp nhận được có điều kiện (cần dải bảo vệ, xem bài tập 1); dưới 4:1 hệ đo không đủ năng lực cho dung sai này. Doanh nghiệp có hệ thống chất lượng thường đánh giá chính thức bằng nghiên cứu Gage R&R; tỉ số ở đây là bước kiểm tra nhanh trước đó.

import csv

import numpy as np

THAT = {"rong": 8.012, "cao": 5.000}       # giá trị thật (đo bằng máy CMM)
DUNG_SAI = 0.05                            # bản vẽ: ±0,05 mm → dải dung sai 0,10 mm

dong = list(csv.DictReader(open("ket_qua_lap.csv")))
print("kích thước   |  TB (mm) |  σ (µm) | ±3σ (µm) | bias (µm) | dải DS / 2(|bias|+3σ)")
for cot in ("rong_tho", "rong", "cao_tho", "cao"):
    x = np.array([float(d[cot]) for d in dong])
    tb, s = x.mean(), x.std(ddof=1)
    bias = tb - THAT[cot.replace("_tho", "")]
    ti_so = 2 * DUNG_SAI / (2 * (abs(bias) + 3 * s))
    print(f"{cot:12s} | {tb:8.4f} | {1000 * s:7.2f} | {3000 * s:8.2f} | {1000 * bias:+9.2f} | {ti_so:6.1f}")
kích thước   |  TB (mm) |  σ (µm) | ±3σ (µm) | bias (µm) | dải DS / 2(|bias|+3σ)
rong_tho     |   8.0037 |    2.13 |     6.38 |     -8.26 |    3.4
rong         |   8.0124 |    2.13 |     6.38 |     +0.42 |    7.4
cao_tho      |   4.9919 |    1.38 |     4.15 |     -8.06 |    4.1
cao          |   5.0006 |    1.38 |     4.15 |     +0.61 |   10.5

Đọc bảng như một kỹ sư đo lường:

  • Trước khi bù, bề rộng có bias −8,3 µm và tỉ số chỉ 3,4: không đạt 4:1. Hệ quả: chi tiết thật 7,955 mm (đạt) bị đo thành khoảng 7,947 mm và bị loại oan (overkill); chi tiết thật 8,055 mm (không đạt) bị đo thành khoảng 8,047 mm và lọt qua (escape).
  • Sau khi bù bằng căn mẫu, bias còn dưới 1 µm, tỉ số bề rộng 7,4 (chấp nhận được có điều kiện), chiều cao 10,5 (tốt).
  • Vì sao σ của bề rộng (2,1 µm) lớn hơn chiều cao (1,4 µm)? Phần lớn σ đến từ việc chi tiết lên xuống ±0,1 mm: sai số tỉ lệ ΔZ/Z = 0,05% nhân với kích thước. Kích thước lớn hơn thì σ lớn hơn. Muốn tăng tỉ số bề rộng lên 10:1, cách hiệu quả nhất không phải thuật toán mà là đồ gá giữ chiều cao (hoặc ống kính telecentric).
Quy trình tại hiện trường(1) Hiệu chuẩn tỉ lệ bằng tấm lưới ở đúng mặt phẳng đo; kiểm tra phần dư. (2) Đo căn mẫu hoặc vòng chuẩn để lấy giá trị bù biên cho từng loại kích thước. (3) Đo một chi tiết đã có số CMM 30 lần, mỗi lần nhấc ra đặt lại như sản xuất thật. (4) Tính σ, bias, tỉ số so với dung sai; ghi hồ sơ. (5) Chỉ đưa vào sản xuất khi tỉ số đạt yêu cầu đã thống nhất với khách hàng.

7. Khi nào quy đổi đơn giản không còn đủ?

Đoạn mô phỏng dưới đây dùng mô hình camera đầy đủ của OpenCV (cv2.projectPoints): ống kính 25 mm, cảm biến 2448 × 2048, pixel 3,45 µm, cách vật 200 mm (FOV khoảng 68 × 57 mm). Hệ số tỉ lệ được hiệu chuẩn ở tâm ảnh. Sau đó đo một đoạn 10 mm ở tâm, giữa và gần góc ảnh, với bốn tình huống:

import math

import cv2
import numpy as np

f_px = 25 / 0.00345                        # ống kính 25 mm, pixel 3,45 µm
K = np.array([[f_px, 0, 1224], [0, f_px, 1024], [0, 0, 1]])   # cảm biến 2448 × 2048
Z = 200.0                                  # FOV khoảng 68 × 57 mm


def do_doan(cx, cy, dist, goc_nghieng=0.0, cao=0.0, L=10.0):
    """Chiều dài (pixel) trên ảnh của đoạn L mm nằm ngang, tâm (cx, cy) mm, cao `cao` mm trên mặt phẳng."""
    pts = np.array([[cx - L / 2, cy, -cao], [cx + L / 2, cy, -cao]], np.float64)
    rvec = np.array([math.radians(goc_nghieng), 0, 0])
    uv, _ = cv2.projectPoints(pts, rvec, np.array([0, 0, Z]), K, dist)
    return np.linalg.norm(uv[1, 0] - uv[0, 0])


khong_meo = np.zeros(5)
meo = np.array([-0.1, 0, 0, 0, 0])         # méo thùng nhẹ: khoảng -0,5% ở góc ảnh
truong_hop = [("Lý tưởng", khong_meo, 0, 0), ("Méo thùng k1 = -0,1", meo, 0, 0),
              ("Camera nghiêng 2°", khong_meo, 2, 0), ("Vật cao hơn 1 mm", khong_meo, 0, 1)]
vi_tri = [(0, 0), (15, 12), (28, 24)]
print("Sai số đo đoạn 10 mm (µm), tỉ lệ hiệu chuẩn ở tâm ảnh trên mặt phẳng chuẩn")
print(f"{'trường hợp':22s}" + "".join(f"  tại ({x:2d},{y:2d})" for x, y in vi_tri))
for ten, dist, nghieng, cao in truong_hop:
    s0 = 10.0 / do_doan(0, 0, dist, nghieng)                 # mm/px đo bằng vật chuẩn ở tâm
    sai = [1000 * (do_doan(x, y, dist, nghieng, cao) * s0 - 10.0) for x, y in vi_tri]
    print(f"{ten:22s}" + "".join(f"  {e:+11.1f}" for e in sai))
Sai số đo đoạn 10 mm (µm), tỉ lệ hiệu chuẩn ở tâm ảnh trên mặt phẳng chuẩn
trường hợp              tại ( 0, 0)  tại (15,12)  tại (28,24)
Lý tưởng                       +0.0         +0.0         +0.0
Méo thùng k1 = -0,1            +0.0        -20.5        -73.1
Camera nghiêng 2°              +0.0        -20.9        -41.7
Vật cao hơn 1 mm              +50.3        +50.3        +50.3
Tình huốngĐặc điểm sai sốCách nhận raGiải pháp
Méo ống kính0 ở tâm, tăng nhanh ra mép (−73 µm gần góc ảnh, dù méo chỉ cỡ 0,5% ở góc)Phần dư fit lưới lớn dần ra mép theo dạng xuyên tâmBài tiếp theo: Hiệu chuẩn camera: nội tại và méo ống kính
Camera nghiêngThay đổi tuyến tính theo một hướng (−42 µm ở y = 24 mm với 2°)sx ≠ sy; phần dư nghiêng một chiềuChỉnh lại giá camera; hoặc Homography: đo trên mặt phẳng khi camera nghiêng
Vật cao hơn mặt hiệu chuẩnNhư nhau ở mọi vị trí (+50 µm cho 1 mm chênh cao)Đo căn mẫu kê cao sẽ thấy ngayHiệu chuẩn đúng độ cao, đồ gá, hoặc telecentric

Kết luận thực hành. Quy đổi đơn giản (sx, sy cộng giá trị bù biên) là đủ khi tất cả các điều kiện sau đúng:

  1. Phần dư lớn nhất khi fit lưới chuẩn phủ toàn vùng đo nhỏ hơn khoảng 1/10 dải dung sai.
  2. Chiều cao các đặc trưng đo nằm trong một mặt phẳng ổn định: L · ΔZ / Z nhỏ so với dải dung sai.
  3. Thử nghiệm lặp lại ở mục 6 đạt tỉ số đã thống nhất, với chi tiết đặt ở nhiều vị trí khác nhau trong ảnh (tâm và mép), không chỉ ở tâm.

Rất nhiều trạm đo thực tế thỏa mãn được ba điều kiện này: ống kính tiêu cự dài hoặc telecentric, camera vuông góc, đồ gá tốt, dung sai cỡ vài phần trăm mm trở lên. Khi một điều kiện không đạt, đừng cố "chỉnh hệ số cho khớp" bằng tay: hãy chuyển sang mô hình đúng cho nguồn sai số đó.

8. Những lỗi thường gặp

  • Lấy tỉ lệ từ đường kính chấm hoặc chiều dài vật thay vì khoảng cách tâm: tỉ lệ bị lẫn sai lệch biên, rồi lại dùng tỉ lệ đó đo lỗ (sai lệch biên ngược dấu) nên sai gấp đôi.
  • Hiệu chuẩn bằng tấm chuẩn đặt trên mặt bàn trong khi chi tiết nằm trên đồ gá cao 5 mm.
  • Chỉ đo lặp lại mà không nhấc chi tiết ra đặt lại: σ rất đẹp nhưng không phản ánh sản xuất thật (sai lệch vị trí, chiều cao, góc xoay).
  • Đo lặp lại chỉ ở tâm ảnh, bỏ qua méo ở mép.
  • Báo cáo kết quả với 4 chữ số thập phân khi σ là 5 µm: số chữ số không phải là độ chính xác.
  • Quên hiệu chuẩn lại sau khi xoay vòng nét, đổi khẩu độ hoặc thay đèn.

9. Bài tập

Bài 1. Một trạm đo khe hở có dung sai ±0,03 mm. Thử lặp lại cho σ = 4 µm, bias = +6 µm. Tính tỉ số năng lực trước và sau khi bù bias. Để đạt 4:1 và 10:1 sau khi bù, σ phải nhỏ hơn bao nhiêu?

Xem đáp án
dung_sai = 0.03                 # ±0,03 mm
sigma, bias = 0.004, 0.006      # mm
dai_sai_so = 2 * (abs(bias) + 3 * sigma)
print(f"Dải sai số đo {dai_sai_so:.3f} mm, tỉ số {2 * dung_sai / dai_sai_so:.2f}")
print(f"Sau khi bù bias: tỉ số {2 * dung_sai / (6 * sigma):.2f}")
print(f"σ cần để đạt 4:1 sau bù: ≤ {2 * dung_sai / 4 / 6 * 1000:.1f} µm; đạt 10:1: ≤ {2 * dung_sai / 10 / 6 * 1000:.1f} µm")
Dải sai số đo 0.036 mm, tỉ số 1.67
Sau khi bù bias: tỉ số 2.50
σ cần để đạt 4:1 sau bù: ≤ 2.5 µm; đạt 10:1: ≤ 1.0 µm

Hệ đo này không dùng được cho dung sai ±0,03 mm kể cả khi bù bias: cần σ ≤ 2,5 µm để đạt 4:1. Hướng cải thiện: giảm FOV để pixel nhỏ hơn, giảm dao động chiều cao, cải thiện chiếu sáng biên. Nếu tạm thời phải dùng hệ đo ở mức 4:1 đến 10:1, áp dụng dải bảo vệ (guard band): thu hẹp giới hạn chấp nhận vào trong mỗi phía một khoảng cỡ 3σ để giảm nguy cơ lọt hàng lỗi, đổi lại tăng loại oan.

Bài 2. Dùng ảnh căn mẫu và ảnh tấm chuẩn đã tạo ở mục 3, đo bề rộng căn mẫu và bước lưới (khoảng cách tâm chấm, tính bằng nhị phân hóa rồi lấy trọng tâm vùng) với mức cắt 30%, 50%, 70%. Kết quả chứng minh điều gì?

Xem đáp án
import glob
import json

import cv2
import numpy as np

tl = json.load(open("ti_le.json"))
cm = cv2.imread(sorted(glob.glob("can_mau/*.png"))[0], cv2.IMREAD_GRAYSCALE).astype(np.float64)
tc = cv2.imread(sorted(glob.glob("tam_chuan/*.png"))[0], cv2.IMREAD_GRAYSCALE)
nen, vat = np.median(cm), np.percentile(cm, 1)

print("mức cắt | rộng căn mẫu (mm) | bước lưới từ tâm chấm (px)")
for p in (0.3, 0.5, 0.7):
    muc = vat + p * (nen - vat)
    rong = []
    for r in range(200, 280):                                   # các hàng cắt ngang căn mẫu
        prof = cm[r]
        duoi = np.where(prof < muc)[0]
        i, j = duoi[0], duoi[-1]
        trai = i - 1 + (prof[i - 1] - muc) / (prof[i - 1] - prof[i])
        phai = j + (prof[j] - muc) / (prof[j] - prof[j + 1])
        rong.append(phai - trai)
    _, nhi_phan = cv2.threshold(tc, muc, 255, cv2.THRESH_BINARY_INV)
    n, _, st, tam = cv2.connectedComponentsWithStats(nhi_phan.astype(np.uint8))
    tam = tam[1:][st[1:, cv2.CC_STAT_AREA] > 50]
    hang_dau = np.sort(tam[tam[:, 1] < tam[:, 1].min() + 20][:, 0])  # 5 chấm hàng trên cùng
    print(f"  {int(100 * p)}%   | {np.mean(rong) * tl['sx']:17.4f} | {np.diff(hang_dau).mean():10.3f}")
mức cắt | rộng căn mẫu (mm) | bước lưới từ tâm chấm (px)
  30%   |            9.9632 |     99.994
  50%   |            9.9877 |    100.003
  70%   |           10.0156 |     99.992

Bề rộng căn mẫu thay đổi hơn 50 µm chỉ vì đổi mức cắt (mức thấp thì biên vật tối "co" vào), trong khi bước lưới giữ nguyên khoảng 100,00 px vì tâm chấm không phụ thuộc mức cắt. Đó là lý do: lấy tỉ lệ từ khoảng cách tâm, lấy giá trị bù biên từ vật chuẩn cạnh tới cạnh, và giữ mức cắt cố định giữa lúc hiệu chuẩn và lúc đo.

Bài 3. Camera line scan có sx = 0,050 mm/px (đo bằng tấm chuẩn). Encoder 5000 xung/vòng gắn trên con lăn Ø50 mm, camera chụp một dòng mỗi 2 xung. Tính sy. Một chi tiết tròn Ø10 mm hiện ra trên ảnh như thế nào? Muốn pixel "vuông" thì cần bao nhiêu xung mỗi dòng?

Xem đáp án
import math

sx = 0.050                                   # mm/px theo chiều ngang, đo bằng tấm chuẩn
chu_vi = math.pi * 50.0                      # con lăn encoder Ø50 mm
mm_moi_xung = chu_vi / 5000                  # encoder 5000 xung/vòng
sy = 2 * mm_moi_xung                         # mỗi 2 xung chụp 1 dòng
print(f"mm/xung = {mm_moi_xung:.6f}, sy = {sy:.6f} mm/dòng, sy/sx = {sy / sx:.4f}")
print(f"Đồng xu Ø10 mm hiện thành elip {10 / sx:.0f} × {10 / sy:.1f} px")
print(f"Muốn sy = sx: {sx / mm_moi_xung:.4f} xung mỗi dòng")
mm/xung = 0.031416, sy = 0.062832 mm/dòng, sy/sx = 1.2566
Đồng xu Ø10 mm hiện thành elip 200 × 159.2 px
Muốn sy = sx: 1.5915 xung mỗi dòng

sy lớn hơn sx 26%: chi tiết tròn hiện thành elip 200 × 159 px. Có hai cách: dùng bộ chia/nhân xung có hệ số lẻ (nhiều camera line scan hỗ trợ) để được khoảng 1,59 xung/dòng, hoặc giữ nguyên và quy đổi riêng sx, sy. Lưu ý sy còn phụ thuộc trượt giữa băng tải và con lăn encoder, nên hiệu chuẩn sy bằng vật chuẩn chạy qua băng tải thật, không chỉ tính từ đường kính con lăn.

Tóm tắt

  • Quy đổi đơn giản: mm = pixel × s, với sx, sy riêng. Đúng khi camera vuông góc, ống kính ít méo, đặc trưng nằm trên mặt phẳng hiệu chuẩn.
  • Lấy tỉ lệ từ khoảng cách tâm trên tấm lưới chuẩn (không phụ thuộc ngưỡng); lấy giá trị bù biên từ căn mẫu, vòng chuẩn bằng chính thuật toán đo. Bù ngược dấu cho kích thước ngoài và kích thước trong.
  • Đánh giá hệ đo: đo lặp lại ≥ 30 lần trong điều kiện thật, tính σ, ±3σ, bias, tỉ số dải dung sai / 2(|bias| + 3σ). Hướng dẫn: ≥ 10:1 tốt, 4:1 tới 10:1 có điều kiện, dưới 4:1 không đạt.
  • Phần dư fit lưới là "máy dò" méo và nghiêng; sai số đều ở mọi vị trí là dấu hiệu chênh chiều cao.
  • Khi méo, nghiêng hoặc chiều cao vượt ngân sách sai số: chuyển sang hiệu chuẩn đầy đủ, homography, đồ gá hoặc telecentric.

Bài tiếp theo: Hiệu chuẩn camera: nội tại và méo ống kính, nơi bạn mô hình hóa và khử chính loại sai số đã thấy ở mép ảnh trong mục 7.

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í.