⭐ Vision Engineer · Chặng 8Bài 6/6Dự án~120 phút

Dự án: đo kích thước chi tiết cơ khí bằng camera đã hiệu chuẩn

Hiệu chuẩn nội tại và mặt phẳng đo, caliper subpixel, fit đường và tròn trong mm, báo cáo theo dung sai và đánh giá độ lặp lại 10 lần đặt lại.

Dự án: đo kích thước chi tiết cơ khí bằng camera đã hiệu chuẩn

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

  • Kết hợp hiệu chuẩn, đo cạnh, fitting
  • Báo cáo kết quả theo dung sai
  • Đánh giá độ lặp lại

Nên học trước: Kiểm tra hiệu chuẩn định kỳ

Nội dung bài viết
  1. 1. Bài toán
  2. 2. Yêu cầu
  3. 3. Kiến thức sử dụng
  4. 4. Dữ liệu: camera ảo và bộ ảnh
  5. 5. Gợi ý thiết kế
  6. 6. Lời giải
  7. 7. Tự đánh giá
  8. 8. Mở rộng
  9. Tóm tắt

Đây là dự án tổng kết chặng Calibration. Bạn sẽ ghép mọi thứ của chặng này với chặng trước thành một chương trình đo hoàn chỉnh: hiệu chuẩn nội tại bằng bàn cờ (bài Hiệu chuẩn camera: nội tại và méo ống kính), hiệu chuẩn mặt phẳng đo bằng homography (bài Homography: đo trên mặt phẳng khi camera nghiêng), caliper subpixel và fitting (bài Đo cạnh subpixel, fitting đường thẳng và đường tròn), rồi báo cáo theo dung sai và đánh giá độ lặp lại (bài Quy đổi pixel sang mm và đánh giá sai số). Mọi ảnh được tạo bằng một camera ảo có méo ống kính và nghiêng nhẹ, nên bạn biết đáp án tới micromet và tự chấm được bài của mình. Dự án này là bước đệm trực tiếp cho Capstone 1: trạm đo kích thước hoàn chỉnh ở cuối lộ trình; phần giao tiếp PLC, lưu cơ sở dữ liệu và biên bản nghiệm thu để dành cho capstone đó và cho chặng tích hợp tiếp theo.

Mục tiêu dự án(1) Đi trọn chuỗi ảnh → pixel → điểm khử méo → mm trên mặt phẳng đo → kích thước, không dùng một hệ số tỉ lệ chung nào. (2) Đo 4 kích thước của một tấm kẹp, so dung sai, kết luận OK/NG. (3) Đo 10 lần cùng một chi tiết, tính độ lệch chuẩn và so với dung sai. (4) Chứng minh bằng số rằng bỏ qua hiệu chuẩn đầy đủ thì sai bao nhiêu. Thời lượng dự kiến: khoảng 2 giờ.

1. Bài toán

Một xưởng gia công cắt laser tấm kẹp bằng thép dày 1 mm: hình chữ nhật 40 × 25 mm có một lỗ Ø10 mm. QC muốn đo 100% sản phẩm trên một trạm đèn nền, camera 1,2 MP (1280 × 960) với ống kính 12 mm, độ phân giải khoảng 0,1 mm/px. Ống kính có méo thùng nhẹ (khoảng −0,8% ở góc ảnh), camera lắp lệch phương thẳng đứng khoảng 1°. Chi tiết được đặt tay lên bàn đèn nền, lệch vị trí tới ±5 mm và xoay tới ±10°.

Ký hiệuKích thướcDanh địnhDung saiDải T
LChiều dài tấm (khoảng cách hai cạnh ngắn)40,00 mm±0,05 mm0,10 mm
WChiều rộng tấm (khoảng cách hai cạnh dài)25,00 mm±0,05 mm0,10 mm
DĐường kính lỗ10,00 mm±0,03 mm0,06 mm
XKhoảng cách từ cạnh trái tới tâm lỗ12,00 mm±0,05 mm0,10 mm

2. Yêu cầu

MãYêu cầuTiêu chí đạt
Y1Hiệu chuẩn nội tại từ bộ ảnh bàn cờ, lưu YAMLRMS reprojection ≤ 0,1 px trên ảnh mô phỏng; fx sai < 0,2% so với đáp án
Y2Hiệu chuẩn mặt phẳng đo bằng bàn cờ đặt trên bàn đèn: homography từ pixel đã khử méo sang mmSai số trên góc bàn cờ (sau khử méo) ≤ 0,01 mm
Y3Đo L, W, D, X bằng caliper subpixel, fit đường/tròn trong hệ mmChi tiết ở vị trí và góc bất kỳ trong phạm vi cho phép
Y4Báo cáo theo dung sai: danh định, đo được, sai lệch, OK/NG cho từng kích thước và kết luận chungĐúng kết luận cho 3 chi tiết thử (1 OK, 2 NG đúng kích thước lỗi)
Y5Độ lặp lại: đo 10 lần cùng một chi tiết, mỗi lần đặt lạiBáo cáo trung bình, σ, 6σ/T (%) và bias so với giá trị CMM; 6σ/T ≤ 20% cho mọi kích thước
Y6So sánh với cách đo "ngây thơ" (một hệ số mm/px, không khử méo)Bảng sai số hai cách trên cùng bộ ảnh

3. Kiến thức sử dụng

  • Hiệu chuẩn camera: nội tại và méo ống kính: findChessboardCornersSB, calibrateCamera với CALIB_FIX_K3, undistortPoints(..., P=K), lưu cv2.FileStorage.
  • Homography: đo trên mặt phẳng khi camera nghiêng: findHomography, perspectiveTransform, nguyên tắc "đổi điểm biên sang mm rồi mới fit".
  • Đo cạnh subpixel, fitting đường thẳng và đường tròn: caliper với cực tính, nội suy parabol đỉnh gradient, fit đường bằng SVD, fit tròn Kasa.
  • Đặc trưng contour và blob: Otsu, contour cha – con, minAreaRect để định vị thô.
  • Quy đổi pixel sang mm và đánh giá sai số: σ, 6σ/T, bias, dải bảo vệ.

4. Dữ liệu: camera ảo và bộ ảnh

Đoạn code này đóng vai "trạm thật": nó tạo ra mọi ảnh bạn cần trong thư mục du_an8/ và giữ đáp án trong du_an8/dap_an.json. Lời giải không được đọc file đáp án, trừ khi để chấm điểm. Cách vẽ giống các bài trước: mỗi pixel → tia sáng (bỏ méo) → giao mặt phẳng → tô màu theo khoảng cách có dấu tới biên hình, nên biên chính xác dưới pixel và có méo thật.

import json
import math
import os

import cv2
import numpy as np

W, H = 1280, 960
K_THAT = np.array([[2600.0, 0, 646.0], [0, 2600.0, 478.0], [0, 0, 1]])
DIST_THAT = np.array([-0.09, 0.03, 0.0004, -0.0003, 0.0])
# Mặt phẳng đo (mặt bàn đèn nền) trong hệ camera: nghiêng khoảng 1°, cách 260 mm
R_BAN = cv2.Rodrigues(np.radians([1.2, -0.6, 0.0]))[0] @ cv2.Rodrigues(np.radians([0, 0, 0.8]))[0]
T_BAN = np.array([-40.0, -25.0, 260.0])
COT, HANG, O = 9, 6, 10.0                     # bàn cờ 9 × 6 góc trong, ô 10 mm
os.makedirs("du_an8", exist_ok=True)

uu, vv = np.meshgrid(np.arange(W, dtype=np.float64), np.arange(H, dtype=np.float64))
dung = (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 40, 1e-10)
TIA = cv2.undistortPoints(np.stack([uu.ravel(), vv.ravel()], 1).reshape(-1, 1, 2), K_THAT, DIST_THAT,
                          None, None, None, dung).reshape(H, W, 2)


def toa_do_mat_phang(R, t):
    """Tọa độ (X, Y) mm trên mặt phẳng Z = 0 của vật cho từng pixel, và cỡ pixel tại chỗ (mm)."""
    Hi = np.linalg.inv(np.column_stack([R[:, 0], R[:, 1], t]))
    x, y = TIA[..., 0], TIA[..., 1]
    m = Hi[2, 0] * x + Hi[2, 1] * y + Hi[2, 2]
    X = (Hi[0, 0] * x + Hi[0, 1] * y + Hi[0, 2]) / m
    Y = (Hi[1, 0] * x + Hi[1, 1] * y + Hi[1, 2]) / m
    return X, Y, np.maximum(np.hypot(*np.gradient(X)), np.hypot(*np.gradient(Y)))


def hoan_thien(anh, rng):
    anh = cv2.GaussianBlur(anh, (0, 0), 0.9) + rng.normal(0, 2.0, anh.shape)
    return np.clip(np.round(anh), 0, 255).astype(np.uint8)


def ve_ban_co(R, t, rng):
    X, Y, p = toa_do_mat_phang(R, t)
    sx = np.clip(O / math.pi * np.sin(math.pi * X / O) / (p / 2), -1, 1)
    sy = np.clip(O / math.pi * np.sin(math.pi * Y / O) / (p / 2), -1, 1)
    o_co = (X > -O) & (X < COT * O) & (Y > -O) & (Y < HANG * O)
    giay = (X > -2 * O) & (X < (COT + 1) * O) & (Y > -2 * O) & (Y < (HANG + 1) * O)
    anh = np.where(giay, np.where(o_co, 125 - 95 * sx * sy, 220.0), 60.0)
    return hoan_thien(anh, rng)


def ve_tam_kep(ct, cx, cy, goc_do, rng):
    """Tấm kẹp kích thước ct = {L, W, D, X}, tâm hình chữ nhật (cx, cy) mm, xoay goc_do, trên đèn nền."""
    X, Y, p = toa_do_mat_phang(R_BAN, T_BAN)
    g = math.radians(goc_do)
    xl = (X - cx) * math.cos(g) + (Y - cy) * math.sin(g)          # tọa độ trong hệ chi tiết
    yl = -(X - cx) * math.sin(g) + (Y - cy) * math.cos(g)
    qx, qy = np.abs(xl) - ct["L"] / 2, np.abs(yl) - ct["W"] / 2      # khoảng cách có dấu tới hình chữ nhật
    sd_hcn = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0)
    sd_lo = np.hypot(xl - (ct["X"] - ct["L"] / 2), yl) - ct["D"] / 2
    sd = np.maximum(sd_hcn, -sd_lo)                                  # hình chữ nhật trừ lỗ
    phu = np.clip(0.5 - sd / p, 0, 1)
    return hoan_thien(230.0 - 205.0 * phu, rng)


rng = np.random.default_rng(8)
# (a) 12 ảnh bàn cờ ở nhiều tư thế để hiệu chuẩn nội tại
objp = np.zeros((COT * HANG, 3))
objp[:, :2] = np.mgrid[0:COT, 0:HANG].T.reshape(-1, 2) * O
k = 0
while k < 12:
    R = cv2.Rodrigues(np.radians(rng.uniform(-25, 25, 3) * [1, 1, 0.8]))[0]
    t = np.array([*rng.uniform(-55, 55, 2), rng.uniform(230, 300)]) - R @ np.array([40.0, 25.0, 0])
    uv = cv2.projectPoints(objp, cv2.Rodrigues(R)[0], t, K_THAT, DIST_THAT)[0].reshape(-1, 2)
    if uv.min() < 70 or uv[:, 0].max() > W - 70 or uv[:, 1].max() > H - 70:
        continue
    cv2.imwrite(f"du_an8/calib_{k:02d}.png", ve_ban_co(R, t, rng))
    k += 1
# (b) bàn cờ nằm phẳng trên bàn đèn: định nghĩa hệ mm của mặt phẳng đo
cv2.imwrite("du_an8/mat_phang.png", ve_ban_co(R_BAN, T_BAN, rng))

# (c) chi tiết: A đo lặp lại 10 lần; A, B, C cho báo cáo dung sai (giá trị CMM)
CHI_TIET = {"A": {"L": 40.012, "W": 24.991, "D": 10.008, "X": 12.004},
            "B": {"L": 39.996, "W": 25.004, "D": 10.041, "X": 11.990},
            "C": {"L": 40.063, "W": 25.010, "D": 9.995, "X": 12.021}}
vi_tri = []
for lan in range(10):
    cx, cy, goc = 40 + rng.uniform(-5, 5), 25 + rng.uniform(-5, 5), rng.uniform(-10, 10)
    vi_tri.append([cx, cy, goc])
    cv2.imwrite(f"du_an8/A_lan{lan:02d}.png", ve_tam_kep(CHI_TIET["A"], cx, cy, goc, rng))
for ten in "ABC":
    cv2.imwrite(f"du_an8/{ten}.png", ve_tam_kep(CHI_TIET[ten], 40 + rng.uniform(-5, 5),
                                                  25 + rng.uniform(-5, 5), rng.uniform(-10, 10), rng))
json.dump({"K": K_THAT.tolist(), "dist": DIST_THAT.tolist(), "chi_tiet": CHI_TIET, "vi_tri_A": vi_tri},
          open("du_an8/dap_an.json", "w"))
print("Đã tạo:", len(os.listdir("du_an8")), "file trong du_an8/")
Đã tạo: 27 file trong du_an8/

5. Gợi ý thiết kế

  • Hai lần hiệu chuẩn, hai vai trò. Nội tại (K, dist) mô tả camera; homography mô tả mặt phẳng đo trong hệ camera. Homography phải tính từ góc bàn cờ đã khử méo (undistortPoints với P=K), và về sau mọi điểm biên cũng phải đi đúng con đường đó: pixel → khử méo → H → mm.
  • Định vị thô trên ảnh, đo tinh trong mm. Dùng contour và minAreaRect để biết chi tiết ở đâu, xoay bao nhiêu, rồi đặt caliper theo hướng các cạnh. Hướng "trái" của chi tiết xác định bằng vị trí lỗ (lỗ gần cạnh trái), không tin vào góc của minAreaRect.
  • Caliper quét từ ngoài vào trong, cực tính sáng → tối, cho cả bốn cạnh; với lỗ, quét từ tâm lỗ ra ngoài cũng là sáng → tối. Bỏ đoạn gần góc (khoảng 15% mỗi đầu cạnh) để tránh bo góc và ba via.
  • Định nghĩa kích thước rõ ràng: L là khoảng cách giữa hai đường fit của hai cạnh ngắn, đo dọc pháp tuyến chung tại tâm; X là khoảng cách từ tâm lỗ tới đường fit cạnh trái.
  • Hàm đo trả về dict, hàm báo cáo nhận dict: tách đo và phán xét để dùng lại ở capstone.
  • Đo lặp lại đúng nghĩa: 10 ảnh khác nhau, mỗi ảnh một lần đặt (vị trí, góc khác nhau), không phải 10 lần chạy trên một ảnh.

6. Lời giải

Hãy tự làm trước. Lời giải gồm 5 bước, mỗi bước một khối code chạy được nối tiếp nhau.

Xem lời giải đầy đủ (5 bước)

Bước 1. Hiệu chuẩn nội tại

import glob

import cv2
import numpy as np

COT, HANG, O = 9, 6, 10.0
objp = np.zeros((COT * HANG, 3), np.float32)
objp[:, :2] = np.mgrid[0:COT, 0:HANG].T.reshape(-1, 2) * O
vat, anh_pts = [], []
for f in sorted(glob.glob("du_an8/calib_*.png")):
    g = cv2.imread(f, cv2.IMREAD_GRAYSCALE)
    ok, c = cv2.findChessboardCornersSB(g, (COT, HANG), flags=cv2.CALIB_CB_ACCURACY)
    if ok:
        vat.append(objp)
        anh_pts.append(c)
rms, K, dist, _, _, _, _, e = cv2.calibrateCameraExtended(vat, anh_pts, g.shape[::-1], None, None,
                                                          flags=cv2.CALIB_FIX_K3)
fs = cv2.FileStorage("du_an8/camera.yaml", cv2.FILE_STORAGE_WRITE)
fs.write("camera_matrix", K)
fs.write("dist_coeffs", dist)
fs.write("rms_px", float(rms))
fs.release()
print(f"{len(anh_pts)} ảnh, RMS {rms:.3f} px, sai số từng ảnh lớn nhất {e.max():.3f} px")
print(f"fx {K[0, 0]:.1f}, fy {K[1, 1]:.1f}, cx {K[0, 2]:.1f}, cy {K[1, 2]:.1f}, k1 {dist[0, 0]:+.4f}, k2 {dist[0, 1]:+.4f}")
12 ảnh, RMS 0.019 px, sai số từng ảnh lớn nhất 0.029 px
fx 2599.9, fy 2599.9, cx 645.8, cy 478.1, k1 -0.0891, k2 +0.0211

Đối chiếu đáp án (fx = 2600, cx = 646, cy = 478, k1 = −0,09, k2 = +0,03): fx sai dưới 0,1%, đạt Y1.

Bước 2. Hiệu chuẩn mặt phẳng đo

import cv2
import numpy as np


def khu_meo(P):
    """Pixel ảnh gốc → pixel của camera không méo cùng K."""
    return cv2.undistortPoints(np.asarray(P, np.float64).reshape(-1, 1, 2), K, dist, P=K).reshape(-1, 2)


g = cv2.imread("du_an8/mat_phang.png", cv2.IMREAD_GRAYSCALE)
ok, goc = cv2.findChessboardCornersSB(g, (COT, HANG), flags=cv2.CALIB_CB_ACCURACY)
Hmm, _ = cv2.findHomography(khu_meo(goc), objp[:, :2].astype(np.float64))    # pixel khử méo → mm


def px_sang_mm(P):
    return cv2.perspectiveTransform(khu_meo(P).reshape(-1, 1, 2), Hmm).reshape(-1, 2)


du = np.linalg.norm(px_sang_mm(goc) - objp[:, :2], axis=1)
print(f"Sai số trên 54 góc: trung bình {1000 * du.mean():.1f} µm, lớn nhất {1000 * du.max():.1f} µm")
s_tam = np.mean(np.linalg.norm(np.diff(px_sang_mm(goc).reshape(HANG, COT, 2), axis=1), axis=2)) / \
    np.mean(np.linalg.norm(np.diff(goc.reshape(HANG, COT, 2), axis=1), axis=2))
print(f"Tỉ lệ trung bình quanh tâm ảnh: {1000 * s_tam:.2f} µm/px")
# Nếu so với một điểm của bàn cờ mà không khử méo:
H_tho, _ = cv2.findHomography(goc.reshape(-1, 2).astype(np.float64), objp[:, :2].astype(np.float64))
du_tho = np.linalg.norm(cv2.perspectiveTransform(goc.reshape(-1, 1, 2).astype(np.float64), H_tho).reshape(-1, 2)
                        - objp[:, :2], axis=1)
print(f"Cùng homography nhưng KHÔNG khử méo: sai số lớn nhất {1000 * du_tho.max():.1f} µm")
Sai số trên 54 góc: trung bình 1.2 µm, lớn nhất 3.5 µm
Tỉ lệ trung bình quanh tâm ảnh: 100.62 µm/px
Cùng homography nhưng KHÔNG khử méo: sai số lớn nhất 52.0 µm

Thứ tự góc mà findChessboardCornersSB trả về quyết định gốc và chiều của hệ mm. Với phép đo kích thước (khoảng cách, đường kính) điều này không quan trọng vì mọi hệ trực chuẩn đều cho cùng khoảng cách. Nếu cần tọa độ tuyệt đối (gửi vị trí cho robot), phải cố định thứ tự như bài Hiệu chuẩn camera đã nhắc. Dòng cuối cho thấy homography không thay được khử méo: bỏ bước khử méo, sai số trên chính các góc dùng để fit đã lên hàng chục micromet.

Bước 3. Đo một chi tiết

import math

import cv2
import numpy as np


def tim_canh(anh, tam, huong, dai=40, cuc=-1, nguong=20):
    """Caliper: quét đoạn dài `dai` px qua `tam` theo `huong`; trả về điểm cạnh subpixel (x, y) hoặc None.
    cuc = -1: cạnh sáng → tối theo chiều quét."""
    hx, hy = np.asarray(huong, np.float64) / np.hypot(*huong)
    t = np.arange(-dai / 2, dai / 2 + 1)
    mx = (tam[0] + t * hx).astype(np.float32).reshape(1, -1)
    my = (tam[1] + t * hy).astype(np.float32).reshape(1, -1)
    p = cv2.remap(anh, mx, my, cv2.INTER_CUBIC).ravel().astype(np.float64)
    gr = np.zeros_like(p)
    gr[1:-1] = (p[2:] - p[:-2]) / 2 * cuc
    k = int(np.argmax(gr))
    if gr[k] < nguong or k < 2 or k > len(gr) - 3:
        return None
    a, b, c = gr[k - 1], gr[k], gr[k + 1]
    s = t[k] + 0.5 * (a - c) / (a - 2 * b + c)
    return tam[0] + s * hx, tam[1] + s * hy


def fit_duong(P):
    c = P.mean(axis=0)
    v = np.linalg.svd(P - c)[2][0]
    return c, np.array([-v[1], v[0]])                    # điểm trên đường, pháp tuyến đơn vị


def fit_tron(P):
    A = np.column_stack([P, np.ones(len(P))])
    D, E, F = np.linalg.lstsq(A, -(P ** 2).sum(axis=1), rcond=None)[0]
    return np.array([-D / 2, -E / 2]), math.sqrt(D * D / 4 + E * E / 4 - F)


def do_tam_kep(anh8, doi_mm=None):
    """Đo L, W, D, X (mm). doi_mm: hàm đổi mảng điểm pixel → mm (mặc định: hiệu chuẩn đầy đủ)."""
    doi_mm = doi_mm or px_sang_mm
    anh = anh8.astype(np.float32)
    _, bw = cv2.threshold(anh8, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    cs, hier = cv2.findContours(bw, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE)
    i = max(range(len(cs)), key=lambda j: cv2.contourArea(cs[j]))
    lo = [cs[j] for j in range(len(cs)) if hier[0][j][3] == i and cv2.contourArea(cs[j]) > 500]
    if not lo:
        raise ValueError("không thấy lỗ")
    (cx, cy), (w, h), _ = cv2.minAreaRect(cs[i])
    m = cv2.moments(lo[0])
    lx, ly = m["m10"] / m["m00"], m["m01"] / m["m00"]
    ux = np.array([cx - lx, cy - ly]) / math.hypot(cx - lx, cy - ly)    # hướng từ lỗ → tâm: trục +x chi tiết
    uy = np.array([-ux[1], ux[0]])
    dai, rong = max(w, h), min(w, h)
    canh = {}
    for ten, goc_canh, phap, nua, doc in (("trai", -1, ux, dai / 2, rong), ("phai", +1, ux, dai / 2, rong),
                                          ("tren", -1, uy, rong / 2, dai), ("duoi", +1, uy, rong / 2, dai)):
        doc_canh = np.array([phap[1], -phap[0]])                         # hướng chạy dọc cạnh
        diem = []
        for s in np.linspace(-0.35, 0.35, 15) * doc:
            tam = np.array([cx, cy]) + goc_canh * nua * phap + s * doc_canh
            q = tim_canh(anh, tam, -goc_canh * phap)                    # quét từ ngoài vào trong
            if q is not None:
                diem.append(q)
        canh[ten] = fit_duong(doi_mm(np.array(diem)))
    r0 = math.sqrt(cv2.contourArea(lo[0]) / math.pi)
    diem_lo = [tim_canh(anh, (lx + r0 * math.cos(a), ly + r0 * math.sin(a)), (math.cos(a), math.sin(a)))
               for a in np.linspace(0, 2 * math.pi, 120, endpoint=False)]
    tam_lo, ban_kinh = fit_tron(doi_mm(np.array([q for q in diem_lo if q is not None])))

    def kc(c1, c2):                                       # khoảng cách giữa hai đường gần song song
        n = c1[1] if np.dot(c1[1], c2[1]) > 0 else -c1[1]
        n = (n + c2[1]) / np.linalg.norm(n + c2[1])
        return abs(np.dot(c2[0] - c1[0], n))

    return {"L": kc(canh["trai"], canh["phai"]), "W": kc(canh["tren"], canh["duoi"]), "D": 2 * ban_kinh,
            "X": abs(np.dot(tam_lo - canh["trai"][0], canh["trai"][1]))}


kq = do_tam_kep(cv2.imread("du_an8/A_lan00.png", cv2.IMREAD_GRAYSCALE))
print("Chi tiết A, lần 0:", {k: round(float(v), 4) for k, v in kq.items()})
Chi tiết A, lần 0: {'L': 40.0164, 'W': 24.9922, 'D': 10.0087, 'X': 12.0068}

Bước 4. Báo cáo theo dung sai

import cv2

BAN_VE = {"L": (40.00, 0.05), "W": (25.00, 0.05), "D": (10.00, 0.03), "X": (12.00, 0.05)}


def bao_cao(ten, kq):
    print(f"Chi tiết {ten}")
    print("  kích thước | danh định | dung sai |  đo được | sai lệch | kết quả")
    tong = "OK"
    for k, (dd, ds) in BAN_VE.items():
        lech = kq[k] - dd
        ok = abs(lech) <= ds
        tong = tong if ok else "NG"
        print(f"      {k}      |  {dd:7.2f}  |  ±{ds:.2f}   | {kq[k]:8.3f} | {lech:+7.3f}  | {'OK' if ok else 'NG'}")
    print(f"  KẾT LUẬN: {tong}")
    return tong


for ten in "ABC":
    bao_cao(ten, do_tam_kep(cv2.imread(f"du_an8/{ten}.png", cv2.IMREAD_GRAYSCALE)))
Chi tiết A
  kích thước | danh định | dung sai |  đo được | sai lệch | kết quả
      L      |    40.00  |  ±0.05   |   40.016 |  +0.016  | OK
      W      |    25.00  |  ±0.05   |   24.992 |  -0.008  | OK
      D      |    10.00  |  ±0.03   |   10.006 |  +0.006  | OK
      X      |    12.00  |  ±0.05   |   12.007 |  +0.007  | OK
  KẾT LUẬN: OK
Chi tiết B
  kích thước | danh định | dung sai |  đo được | sai lệch | kết quả
      L      |    40.00  |  ±0.05   |   40.000 |  +0.000  | OK
      W      |    25.00  |  ±0.05   |   25.006 |  +0.006  | OK
      D      |    10.00  |  ±0.03   |   10.042 |  +0.042  | NG
      X      |    12.00  |  ±0.05   |   11.993 |  -0.007  | OK
  KẾT LUẬN: NG
Chi tiết C
  kích thước | danh định | dung sai |  đo được | sai lệch | kết quả
      L      |    40.00  |  ±0.05   |   40.066 |  +0.066  | NG
      W      |    25.00  |  ±0.05   |   25.011 |  +0.011  | OK
      D      |    10.00  |  ±0.03   |    9.998 |  -0.002  | OK
      X      |    12.00  |  ±0.05   |   12.023 |  +0.023  | OK
  KẾT LUẬN: NG

Kết quả hiển thị 3 chữ số thập phân (micromet) vì σ của hệ đo cỡ micromet (bước 5); hiển thị nhiều hơn là giả chính xác. B bị loại đúng vì lỗ Ø10,041 mm; C bị loại đúng vì dài 40,063 mm. Chú ý A: các kích thước nằm sát nhau quanh danh định nên rõ ràng OK; một chi tiết đo được, ví dụ, L = 40,048 mm thì sao? Với σ vài micromet, nó có thể là 40,052 thật. Đó là lý do dùng dải bảo vệ (bài Quy đổi pixel sang mm) khi kích thước đo được nằm trong khoảng ±3σ quanh giới hạn.

Bước 5. Độ lặp lại và so sánh với cách đo ngây thơ

import glob
import json

import cv2
import numpy as np

that = json.load(open("du_an8/dap_an.json"))["chi_tiet"]["A"]          # giá trị CMM của chi tiết A

# Cách ngây thơ: một hệ số mm/px lấy từ bàn cờ ở tâm ảnh, không khử méo, không homography
def ngay_tho(P):
    return np.asarray(P, np.float64) * s_tam


ket_qua = {"đầy đủ": [], "ngây thơ": []}
for f in sorted(glob.glob("du_an8/A_lan*.png")):
    g = cv2.imread(f, cv2.IMREAD_GRAYSCALE)
    ket_qua["đầy đủ"].append(do_tam_kep(g))
    ket_qua["ngây thơ"].append(do_tam_kep(g, doi_mm=ngay_tho))

for cach, ds in ket_qua.items():
    print(f"Cách đo {cach} (10 lần đặt lại chi tiết A)")
    print("  kt |  trung bình |  σ (µm) | 6σ/T (%) | bias (µm) | đạt Y5")
    for k, (dd, dsai) in BAN_VE.items():
        x = np.array([d[k] for d in ds])
        s = x.std(ddof=1)
        ty_le = 100 * 6 * s / (2 * dsai)
        print(f"  {k}  |  {x.mean():9.4f}  | {1000 * s:6.2f}  |  {ty_le:6.1f}  | {1000 * (x.mean() - that[k]):+8.2f}  | "
              f"{'đạt' if ty_le <= 20 else 'KHÔNG'}")
Cách đo đầy đủ (10 lần đặt lại chi tiết A)
  kt |  trung bình |  σ (µm) | 6σ/T (%) | bias (µm) | đạt Y5
  L  |    40.0156  |   0.82  |     4.9  |    +3.58  | đạt
  W  |    24.9928  |   1.30  |     7.8  |    +1.77  | đạt
  D  |    10.0091  |   1.66  |    16.6  |    +1.13  | đạt
  X  |    12.0058  |   1.64  |     9.8  |    +1.79  | đạt
Cách đo ngây thơ (10 lần đặt lại chi tiết A)
  kt |  trung bình |  σ (µm) | 6σ/T (%) | bias (µm) | đạt Y5
  L  |    40.0879  |  12.14  |    72.8  |   +75.85  | KHÔNG
  W  |    25.0406  |   9.59  |    57.5  |   +49.62  | KHÔNG
  D  |    10.0357  |   3.55  |    35.5  |   +27.73  | KHÔNG
  X  |    12.0393  |   4.19  |    25.1  |   +35.30  | KHÔNG

Đọc hai bảng:

  • Hiệu chuẩn đầy đủ: σ từ 0,8 tới 1,7 µm, 6σ/T từ 5% tới khoảng 17% (D có dải dung sai hẹp nhất nên tỉ lệ cao nhất), bias từ +1 tới +4 µm. Đây là câu trả lời cho bài thực hành: "Đo 10 lần cùng một chi tiết, tính độ lệch chuẩn và so với dung sai yêu cầu": hệ đo đạt Y5, và với D thì ở mức "chấp nhận có điều kiện" theo hướng dẫn 10:1 / 4:1 của bài Quy đổi pixel sang mm.
  • Số tia đo lỗ quyết định σ của D: phiên bản đầu của lời giải dùng 48 tia và được σ = 3,6 µm (6σ/T = 36%, không đạt); tăng lên 120 tia giảm σ khoảng √(120/48) ≈ 1,6 lần, về khoảng 1,7 µm. Đây là "núm vặn" rẻ nhất, trước khi nghĩ tới camera độ phân giải cao hơn.
  • Cách ngây thơ trên cùng 10 ảnh: σ tăng 2–15 lần (3,5–12 µm) vì sai số giờ phụ thuộc vị trí và góc chi tiết trong ảnh (méo và nghiêng thay đổi theo vị trí), và bias lên tới +28 … +76 µm vì tỉ lệ lấy ở tâm bàn cờ không đại diện cho cả vùng chi tiết. Không kích thước nào đạt; L còn bị đẩy sát giới hạn trên dù chi tiết thật chỉ dài hơn danh định 12 µm. Một kỹ sư chỉ thử chi tiết đặt ở giữa ảnh sẽ không bao giờ thấy vấn đề này.
  • Bias còn lại của cách đầy đủ có nguồn gốc giống bài Quy đổi pixel sang mm: vị trí biên phụ thuộc mờ quang học và lấy mẫu; ở trạm thật cần thêm bước bù biên bằng căn mẫu hoặc vòng chuẩn. Capstone 1 làm bước này.

7. Tự đánh giá

Tiêu chíMức đạtMức tốt
Hiệu chuẩn nội tạiRMS ≤ 0,1 px, có lưu YAMLIn sai số từng ảnh, so K với đáp án, giải thích được vì sao cố định k3
Mặt phẳng đoHomography từ góc đã khử méo, sai số góc ≤ 10 µmChứng minh bằng số hậu quả của việc bỏ khử méo
ĐoĐo được L, W, D, X trên mọi ảnh, không lỗiCaliper có cực tính, bỏ vùng góc, fit trong mm; xử lý ảnh không có chi tiết bằng ngoại lệ rõ ràng
Báo cáo dung saiA OK, B NG (D), C NG (L)Có số chữ số hợp lý, nêu được khi nào cần dải bảo vệ
Độ lặp lạiBảng trung bình, σ, 6σ/T, bias cho 4 kích thướcSo sánh được với cách ngây thơ và giải thích nguồn gốc khác biệt
Chất lượng codeChạy từ đầu tới cuối không sửa tayHàm đo trả về dict, tách đo và phán xét, tham số ở một chỗ, dùng lại được cho capstone

8. Mở rộng

  • Bù sai lệch biên: thêm vào mô phỏng hiệu ứng đèn nền "ăn" biên 4 µm (như bài Quy đổi pixel sang mm), tạo ảnh một căn mẫu 30,000 mm và một vòng chuẩn Ø10,000 mm, tính giá trị bù cho kích thước ngoài và kích thước trong (ngược dấu).
  • Chi tiết dày: đổi tấm 1 mm thành 5 mm và mô phỏng camera nhìn thấy cạnh trên; tính sai số khi hiệu chuẩn mặt phẳng ở mặt bàn (bài Homography, mục chiều cao).
  • Đo nhiều chi tiết một ảnh: lặp qua mọi contour ngoài đủ lớn, báo cáo từng chi tiết theo vị trí.
  • Kiểm tra đầu ca: thêm một ảnh mẫu chuẩn và hàm kiểm tra theo ngưỡng như bài Kiểm tra hiệu chuẩn định kỳ; khóa đo nếu không đạt.
  • Hướng tới Capstone 1: tách thành mô-đun hieu_chuan.py, do.py, bao_cao.py, cấu hình TOML; sau chặng tích hợp, thêm giao tiếp PLC và lưu kết quả, rồi làm nghiên cứu độ lặp lại 3 chi tiết × 30 lần như trong Capstone 1: trạm đo kích thước hoàn chỉnh.

Tóm tắt

  • Chuỗi đo đúng: pixel → caliper subpixel → khử méo (K, dist) → homography mặt phẳng đo → mm → fit đường, fit tròn → kích thước. Không có "một hệ số mm/px chung" nào trong chuỗi.
  • Hiệu chuẩn nội tại và hiệu chuẩn mặt phẳng là hai bước khác nhau; homography luôn tính trên điểm đã khử méo.
  • Định vị thô bằng contour, đo tinh bằng caliper có cực tính, định nghĩa từng kích thước rõ ràng theo đường fit.
  • Báo cáo theo dung sai với số chữ số phù hợp với σ; đánh giá độ lặp lại bằng 10 lần đặt lại, so 6σ với dải dung sai và bias với giá trị CMM.
  • Cách ngây thơ có thể đúng ở tâm ảnh nhưng sai và kém lặp lại khi chi tiết dịch chuyển: luôn thử ở nhiều vị trí.

Chặng tiếp theo bắt đầu với bài I/O số và handshake với PLC: đưa kết quả OK/NG của trạm đo này tới dây chuyền, bước tiếp theo trên đường tới Capstone 1.

Bài tập thực hành

  1. Đo 10 lần cùng một chi tiết, tính độ lệch chuẩn và so với dung sai yêu cầu.

Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.

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