⭐ Vision Engineer · Chặng 14Bài 1/5Dự án~360 phút

Capstone: trạm đo kích thước có hiệu chuẩn, %GRR, PLC và nghiệm thu

Dự án portfolio: hiệu chuẩn mm/px, bù sai lệch biên, đo subpixel 4 kích thước, đánh giá %GRR và tuyến tính, trả kết quả PLC Modbus, biên bản tự sinh.

Capstone: trạm đo kích thước có hiệu chuẩn, %GRR, PLC và nghiệm thu

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

  • Hiệu chuẩn, đo, đánh giá độ lặp lại
  • Giao tiếp PLC và lưu kết quả
  • Viết tài liệu nghiệm thu

Nên học trước: Dự án chặng 13: đóng gói ứng dụng vision chạy 24/7

Nội dung bài viết
  1. 1. Bối cảnh
  2. 2. Yêu cầu và tiêu chí nghiệm thu
  3. 3. Kiến thức sử dụng
  4. 4. Kiến trúc tham chiếu
  5. 5. Lộ trình thực hiện theo mốc
  6. 6. Mã khởi đầu và phần lõi
  7. 7. Kiểm thử và nghiệm thu
  8. 8. Sản phẩm bàn giao cho portfolio
  9. 9. Tự đánh giá
  10. 10. Mở rộng
  11. Tóm tắt

Đây là dự án đầu tiên trong ba dự án tổng kết của lộ trình, và là dự án "đúng chất kỹ sư đo lường" nhất. Bạn sẽ xây một trạm đo kích thước hoàn chỉnh: hiệu chuẩn tỉ lệ mm/px bằng tấm lưới chấm, bù sai lệch biên bằng chi tiết chuẩn, định vị chi tiết bị lệch và xoay, đo bốn kích thước bằng caliper subpixel, phán xét theo dung sai bản vẽ, trả kết quả cho PLC qua Modbus TCP, lưu SQLite và ảnh NG, rồi chứng minh trạm đạt yêu cầu bằng nghiên cứu độ lặp lại, bộ mẫu vàng và một biên bản nghiệm thu tự sinh. Dự án dùng kiến thức của các chặng Measurement / Matching / OCR, Calibration, Industrial Vision và PLC / Robot / MES. Mọi thứ chạy trên máy tính với ảnh tổng hợp có đáp án biết trước, nên bạn kiểm chứng được từng micromet. Hai dự án sau là trạm đọc DataMatrix và số seri, và trạm kiểm tra lỗi bề mặt bằng AI.

Mục tiêu dự án(1) Hiệu chuẩn, đo, đánh giá độ lặp lại theo cách một phòng QC chấp nhận. (2) Giao tiếp PLC và lưu kết quả có truy vết. (3) Viết tài liệu nghiệm thu: biên bản với tiêu chí bằng con số, tự sinh từ dữ liệu thử. Thời lượng dự kiến: khoảng 6 giờ, chia thành 6 mốc ở mục 5.

1. Bối cảnh

Một xưởng dập kim loại sản xuất tấm đế thép (bracket) dày 2 mm cho khách hàng lắp ráp điện tử. Mỗi tấm là hình chữ nhật 60 × 36 mm bo bốn góc R3, có hai lỗ Ø8 mm để bắt vít. Khuôn dập liên hoàn chạy 30 nhát/phút, tức 2,0 s một sản phẩm. Hiện QC đo bằng thước kẹp 5 tấm mỗi giờ; tháng trước khách hàng trả về một lô vì khoảng cách lỗ lệch, do chày đột bị mòn và dịch chuyển dần trong ca đêm mà không ai phát hiện kịp. Xưởng muốn đo 100% sản phẩm ngay sau máy dập.

Bản vẽ ghi các kích thước cần kiểm:

Ký hiệuKích thướcDanh địnhDung saiDải dung sai TNguyên nhân lỗi thường gặp
LChiều dài tấm60,00 mm±0,10 mm0,20 mmBước cấp phôi sai, dao cắt mòn
PKhoảng cách tâm hai lỗ40,00 mm±0,05 mm0,10 mmChày đột lỏng, dịch chuyển
D1, D2Đường kính lỗ trái, phải8,00 mm±0,05 mm0,10 mmChày mòn, ba via (burr) mép lỗ

Bố trí trạm: tấm rơi xuống băng tải, xi lanh khí nén chặn tấm trên một bàn đèn nền (backlight). Cữ chặn không định vị chính xác: tấm lệch tối đa ±3 mm và xoay tối đa ±5°. PLC bật TRIGGER khi tấm đã dừng; trạm vision phải trả kết quả trong 300 ms (ngân sách thời gian do PLC đặt, phần còn lại của 2,0 s dành cho băng tải và cơ cấu gạt NG). Cấu hình quang học đề xuất cho trạm thật: camera đơn sắc khoảng 5 MP (2448 × 2048), ống kính telecentric hoặc ống kính thường kèm đồ gá giữ tấm luôn ở cùng độ cao, FOV khoảng 100 × 84 mm, tức khoảng 0,041 mm/px. Bạn đã làm bài toán chọn cấu hình tương tự ở bài tập chọn camera cho trạm thép dập của chặng Camera / Lens / Lighting.

Phiên bản mô phỏng trong bàiĐể chạy nhanh trên mọi máy, camera giả ở mục 6 dùng ảnh 960 × 720 với tỉ lệ thật 0,085 mm/px, tức thô hơn trạm thật khoảng 2 lần. Ảnh tổng hợp không có rung, bụi, tấm cong vênh, nên con số độ lặp lại bạn thu được là giới hạn dưới của thuật toán, không phải con số trạm thật sẽ đạt. Trên trạm thật, hãy chạy lại đúng các kịch bản thử của mục 7 với chi tiết thật.

2. Yêu cầu và tiêu chí nghiệm thu

Mỗi yêu cầu đi kèm một tiêu chí đo được và một cách thử; đây chính là bảng sẽ xuất hiện trong biên bản tự sinh ở mục 7. Tiêu chí %GRR theo hướng dẫn phân tích hệ thống đo (MSA) đã học ở bài "Đặc tả kỹ thuật trạm vision và nghiệm thu FAT/SAT".

MãYêu cầuTiêu chí đạtCách thử
R1Hiệu chuẩn tỉ lệ mm/px bằng tấm lưới chấm bước 4,000 mmPhần dư lớn nhất của phép fit ≤ 0,10 px; kết quả lưu file có ngàyChụp tấm lưới, fit phép đồng dạng
R2Độ lặp lại của từng kích thước%GRR (thành phần lặp lại, 6σ/T) ≤ 10%3 chi tiết × 30 lần nạp lại
R3Độ đúng (bias) so với CMM|bias| ≤ 10% T (L: 20 µm; P, D: 10 µm)Cùng dữ liệu R2, so với phiếu đo CMM
R4Không lọt lỗi, báo đúng kích thước lỗiEscape = 0 và sai mã lỗi = 0 trên mẫu vàng NG5 mẫu NG × 6 lần, đổi vị trí mỗi lần
R5Không loại nhầmOverkill = 0 trên mẫu vàng OK5 mẫu OK × 6 lần
R6Không có sản phẩm hoặc không định vị được thì không bao giờ báo OKBăng tải trống → kết quả LỖIẢnh nền trống
R7Thời gian xử lýp99 ≤ 150 ms (phần xử lý ảnh, trong ngân sách 300 ms)Ghi thời gian mọi lần đo của R2
R8Giao tiếp PLC Modbus TCP, bắt tay TRIGGER/DONE12/12 chu kỳ, kết quả và số đo PLC nhận khớp CSDLPLC giả chạy 12 sản phẩm, có NG và LỖI
R9Lưu kết quả và ảnh NG có truy vếtMọi chu kỳ có bản ghi; mọi ảnh NG/LỖI tồn tại trên đĩa; bản ghi lưu hệ số hiệu chuẩn đang dùngTruy vấn CSDL sau khi chạy R8
R10Tài liệuREADME, đặc tả 1 trang, hướng dẫn hiệu chuẩn lại, biên bản thử tự sinhXem xét

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

  • Image Processing, bài "Đặc trưng contour và blob analysis": Otsu, contour lớn nhất, minAreaRect và cách tính góc cạnh dài từ boxPoints thay vì tin trực tiếp góc trả về.
  • Measurement / Matching / OCR, bài "Đo cạnh subpixel, fitting đường thẳng và đường tròn với OpenCV": caliper, cực tính cạnh, fit đường bằng SVD, fit tròn Kasa, loại điểm nhiễu. Bài "Template matching trong OpenCV" cho ý tưởng dời công cụ đo theo vị trí vật (position tracking).
  • Calibration, bài "Quy đổi pixel sang mm bằng vật chuẩn và đánh giá sai số đo": tấm lưới chấm, sai lệch biên, bù bằng vật chuẩn, độ lặp lại và bias.
  • Industrial Vision: bài "Tiêu chí OK/NG, escape và overkill" và bài "Đặc tả kỹ thuật trạm vision và nghiệm thu FAT/SAT" (%GRR, bộ mẫu vàng, biên bản).
  • PLC / Robot / MES: bài "Modbus TCP bằng Python với pymodbus" (PLC giả, float32 qua hai thanh ghi, ghi kết quả trước rồi mới bật DONE), bài "Lưu kết quả kiểm tra vào SQLite, ảnh NG theo ngày và dọn ổ đĩa" và dự án trạm vision mô phỏng của chặng đó.
  • Python: tomllib (đọc cấu hình TOML, có sẵn từ Python 3.11), dataclass/lớp, sqlite3, threading.
pip install opencv-python numpy pymodbus
# Bài viết chạy với Python 3.11+, OpenCV 5.0, NumPy 2.5, pymodbus 3.15.0 (device_id=, SimData/SimDevice)

4. Kiến trúc tham chiếu

Nguyên tắc quan trọng nhất: phần đo không biết gì về PLC hay ổ đĩa, và phần giao tiếp không biết gì về OpenCV. Nhờ vậy bạn thử thuật toán trên hàng nghìn ảnh mà không cần PLC, và thay PLC giả bằng PLC thật mà không đụng vào thuật toán.

                 cau_hinh.toml        hieu_chuan.json (mm/px, bù biên, ngày)
                       │                     ▲
                       ▼                     │ ghi khi hiệu chuẩn
┌──────────┐   ảnh  ┌──────────────────────────────────────────┐
│ Camera   │──────►│ BoDo.do(anh)                              │
│ (giả/thật)│       │  dinh_vi() → do_pixel() → quy đổi, bù →    │
└──────────┘       │  phán xét theo dung sai → {kq, mã lỗi, mm} │
      ▲             └───────────────┬──────────────────────────┘
      │ chup(chu_ky)                │ dict kết quả (không phụ thuộc PLC)
┌─────┴──────────────────────────────▼────────────┐
│ Vòng lặp trạm: TRIGGER↑ → BUSY → đo → ghi KQ     │──Modbus TCP──► PLC
│ (một lệnh write_registers) → DONE → chờ TRIGGER↓ │
└─────────────┬───────────────────────────────────┘
              ▼
        SQLite (ket_qua.db) + anh_ng/YYYY-MM-DD/*.png

Công cụ ngoài dây chuyền: hieu_chuan.py (lưới chấm + master), danh_gia_*.py (lặp lại,
tuyến tính, mẫu vàng), bao_cao.py → bao_cao_nghiem_thu.md

Cấu trúc thư mục đề xuất cho repo portfolio:

tram-do-kich-thuoc/
├── README.md                 ← mục 8
├── cau_hinh.toml             dung sai, tham số caliper, địa chỉ PLC
├── docs/
│   ├── dac_ta.md             yêu cầu R1–R10, bảng địa chỉ Modbus
│   ├── huong_dan_hieu_chuan.md
│   └── bao_cao_nghiem_thu.md (tự sinh)
├── tram_do/
│   ├── sinh_du_lieu.py       camera giả có đáp án (mục 6.1)
│   ├── hieu_chuan.py         lưới chấm → mm/px; master → bù biên
│   ├── do_luong.py           tim_canh, fit_duong, fit_tron, dinh_vi, do_pixel
│   ├── bo_do.py              BoDo: pixel → mm → OK/NG
│   ├── plc.py                vòng lặp Modbus, bắt tay
│   ├── luu_tru.py            SQLite, ảnh NG
│   └── main.py
├── danh_gia/                 lap_lai.py, tuyen_tinh.py, mau_vang.py, bao_cao.py
└── tests/                    test_do_luong.py (pytest, ảnh có đáp án)

Bảng địa chỉ Modbus (địa chỉ giao thức, bắt đầu từ 0), cùng quy ước với chặng PLC / Robot / MES. Mã lỗi là bit: một tấm có thể sai nhiều kích thước cùng lúc, PLC hay MES đọc được tất cả.

Địa chỉBên ghiNội dung
coil 0PLCTRIGGER
coil 1, 2, 3VisionREADY, BUSY, DONE
HR 0, 1PLCMã sản phẩm, số chu kỳ
HR 10VisionKết quả: 1 OK, 2 NG, 3 LỖI
HR 11VisionSố chu kỳ của kết quả (trả lại HR 1)
HR 12VisionMã lỗi: bit 0 L, bit 1 P, bit 2 D1, bit 3 D2, bit 8 không định vị được
HR 14–21VisionL, P, D1, D2 (mm), mỗi số float32 chiếm 2 thanh ghi, thứ tự ABCD

Luồng dữ liệu một chu kỳ: PLC ghi mã SP và số chu kỳ → bật TRIGGER → trạm bắt sườn lên, bật BUSY → chụp → định vị → đo → quy đổi và bù → phán xét → ghi HR 10–21 bằng một lệnh → bật DONE → lưu CSDL và ảnh NG → chờ PLC tắt TRIGGER (có timeout) → READY.

File cấu hình là "hợp đồng" giữa bạn và QC: dung sai, số đường quét, cửa sổ tìm cạnh đều nằm ở đây, không nằm rải rác trong code. Toàn văn file ở phần 1 của lời giải (mục 6.3).

5. Lộ trình thực hiện theo mốc

MốcViệc chínhSản phẩmXong khiThời gian
M1Đọc bối cảnh, viết đặc tả 1 trang; chạy bộ sinh dữ liệudocs/dac_ta.md, ảnh mẫuMọi yêu cầu R1–R10 có con số; bảng địa chỉ PLC đã chốt45 phút
M2Hiệu chuẩn tỉ lệ bằng lưới chấmhieu_chuan.py, hieu_chuan.jsonSai số tỉ lệ so với đáp án ẩn < 50 ppm; phần dư ≤ 0,10 px60 phút
M3Định vị, caliper subpixel, fit; bù biên bằng masterdo_luong.py, bo_do.pyTrên ảnh có đáp án, mọi kích thước sai < 5 µm sau bù; đúng mã lỗi90 phút
M4Đánh giá: lặp lại, tuyến tính, mẫu vàngdanh_gia/*.py, lap_lai.csvR2–R7 đạt; giải thích được từng con số60 phút
M5Tích hợp PLC giả, SQLite, ảnh NGplc.py, luu_tru.pyR8–R9 đạt; chạy lại 3 lần liên tiếp không lỗi cổng, không treo60 phút
M6Biên bản tự sinh, README, video demobao_cao_nghiem_thu.md, README, video 2–3 phútNgười lạ clone repo, chạy một lệnh, ra biên bản giống bạn45 phút
Làm theo thứ tự này, không nhảy cócRất nhiều người bắt đầu từ giao diện và PLC vì "nhìn thấy được". Trạm đo sống hay chết ở M2–M4: nếu số đo không đúng và không lặp lại, PLC nhận kết quả nhanh đến mấy cũng vô ích. Mỗi mốc kết thúc bằng một kiểm tra "xong khi" có con số, đừng sang mốc sau khi chưa đạt.

6. Mã khởi đầu và phần lõi

6.1. Mã khởi đầu: camera giả có đáp án

Bộ sinh dữ liệu vẽ ảnh đèn nền bằng hàm khoảng cách có dấu (signed distance function, SDF): với mỗi pixel, tính khoảng cách tới biên vật (âm nếu nằm trong vật liệu), rồi đổi thành tỉ lệ phủ pixel với biên dốc 1 px. Cách này nhanh hơn nhiều so với vẽ ở độ phân giải gấp 8 lần như bài đo cạnh subpixel, mà vẫn đặt biên đúng vị trí subpixel. Sau đó làm mờ (ống kính), thêm nhiễu (cảm biến), và hai hiệu ứng thật: ánh sáng loe 0,15 px vào vật ở mọi cạnh (làm mọi kích thước cạnh-tới-cạnh bị lệch, như đèn nền thật), và độ sáng dao động giữa các lần chụp.

Hằng số K_THAT (0,085 mm/px) là "sự thật" mà chương trình đo không được dùng; chỉ dùng để chấm điểm. Chép khối này vào sinh_du_lieu.py:

# ===== sinh_du_lieu.py — "camera giả" có đáp án biết trước =====
import math

import cv2
import numpy as np

W_ANH, H_ANH = 960, 720          # ảnh mô phỏng (trạm thật: 5 MP, xem mục 1)
K_THAT = 0.0850                  # mm/px THẬT của camera giả. Chương trình đo KHÔNG được dùng hằng số này
LOE_PX = 0.15                    # đèn nền "loe" vào vật 0,15 px ở mọi cạnh (mô phỏng sai lệch biên)
TAM_FOV = (W_ANH * K_THAT / 2, H_ANH * K_THAT / 2)   # tâm khung nhìn (mm)

_X, _Y = np.meshgrid(np.arange(W_ANH, dtype=np.float64), np.arange(H_ANH, dtype=np.float64))
_XM, _YM = _X * K_THAT, _Y * K_THAT                  # tọa độ mm của tâm từng pixel


def _chup(sdf_px, rng, nen=215.0, vat=25.0, mo=0.8, nhieu=2.5):
    """sdf_px: khoảng cách có dấu tới biên (px), âm = trong vật liệu (tối). Trả ảnh xám uint8."""
    phu = np.clip(0.5 - sdf_px, 0.0, 1.0)                     # tỉ lệ vật liệu phủ pixel (biên dốc 1 px)
    anh = nen + (vat - nen) * phu
    anh = cv2.GaussianBlur(anh.astype(np.float32), (0, 0), mo)  # ống kính
    anh = anh + rng.normal(0.0, nhieu, anh.shape)               # nhiễu cảm biến
    return np.clip(anh, 0, 255).round().astype(np.uint8)


def ve_luoi_cham(rng, buoc=4.0, d=2.0, so_cot=15, so_hang=11, goc_deg=1.2, lech=(1.5, -1.0)):
    """Tấm hiệu chuẩn: lưới chấm tối (thủy tinh mạ crôm trên đèn nền), bước `buoc` mm."""
    a = math.radians(goc_deg)
    cx, cy = TAM_FOV[0] + lech[0], TAM_FOV[1] + lech[1]
    dx, dy = _XM - cx, _YM - cy
    u = dx * math.cos(a) + dy * math.sin(a)                     # tọa độ trong hệ của tấm (mm)
    v = -dx * math.sin(a) + dy * math.cos(a)
    i = np.clip(np.round(u / buoc + (so_cot - 1) / 2), 0, so_cot - 1)   # nút lưới gần nhất
    j = np.clip(np.round(v / buoc + (so_hang - 1) / 2), 0, so_hang - 1)
    du = u - (i - (so_cot - 1) / 2) * buoc
    dv = v - (j - (so_hang - 1) / 2) * buoc
    sdf = (np.hypot(du, dv) - d / 2) / K_THAT + LOE_PX
    return _chup(sdf, rng, nhieu=2.0)


def ve_chi_tiet(rng, L=60.0, B=36.0, P=40.0, D1=8.0, D2=8.0, tam=None, goc_deg=0.0, r_goc=3.0,
                loe=LOE_PX, nen=215.0, vat=25.0, nhieu=2.5):
    """Tấm đế dập: chữ nhật L × B (mm) bo góc r_goc, hai lỗ Ø D1 (trái), Ø D2 (phải) cách tâm P."""
    cx, cy = tam if tam is not None else TAM_FOV
    a = math.radians(goc_deg)
    dx, dy = _XM - cx, _YM - cy
    u = dx * math.cos(a) + dy * math.sin(a)
    v = -dx * math.sin(a) + dy * math.cos(a)
    qx, qy = np.abs(u) - (L / 2 - r_goc), np.abs(v) - (B / 2 - r_goc)
    hop = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - r_goc
    lo_trai = np.hypot(u + P / 2, v) - D1 / 2
    lo_phai = np.hypot(u - P / 2, v) - D2 / 2
    sdf_mm = np.maximum(hop, np.maximum(-lo_trai, -lo_phai))           # trong hộp VÀ ngoài hai lỗ
    return _chup(sdf_mm / K_THAT + loe, rng, nen=nen, vat=vat, nhieu=nhieu)


def chup_ngau_nhien(rng, kich_thuoc):
    """Một lần "nạp lại" sản phẩm: lệch ±3 mm, xoay ±5°, đèn dao động ±5 mức xám."""
    tam = (TAM_FOV[0] + rng.uniform(-3, 3), TAM_FOV[1] + rng.uniform(-3, 3))
    return ve_chi_tiet(rng, **kich_thuoc, tam=tam, goc_deg=rng.uniform(-5, 5),
                       nen=215 + rng.normal(0, 5), vat=25 + rng.normal(0, 3))


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    cv2.imwrite("luoi_cham.png", ve_luoi_cham(rng))
    cv2.imwrite("chi_tiet.png", ve_chi_tiet(rng, tam=(41.0, 30.0), goc_deg=2.5))
    print("Đã lưu luoi_cham.png, chi_tiet.png, kích thước ảnh", (W_ANH, H_ANH))
    print(f"Đáp án ẩn: {K_THAT} mm/px; tấm đế L=60.000, P=40.000, D1=D2=8.000 mm, tâm (41.0, 30.0) mm, góc 2.5°")
Đã lưu luoi_cham.png, chi_tiet.png, kích thước ảnh (960, 720)
Đáp án ẩn: 0.085 mm/px; tấm đế L=60.000, P=40.000, D1=D2=8.000 mm, tâm (41.0, 30.0) mm, góc 2.5°

Mở hai ảnh ra xem: tấm lưới 15 × 11 chấm nghiêng 1,2°, và tấm đế tối trên nền sáng với hai lỗ sáng. Hàm chup_ngau_nhien() mô phỏng một lần "nạp lại" sản phẩm trên dây chuyền: vị trí, góc và độ sáng thay đổi mỗi lần; đây là nguồn biến động chính khi đánh giá độ lặp lại.

6.2. Khung chương trình bạn cần viết

Trước khi mở lời giải, hãy tự viết các hàm sau. Chữ ký và "hợp đồng" của từng hàm:

hieu_chuan(anh_luoi, cfg) -> {"mm_moi_px", "phan_du_max_px", "ti_le_hai_truc", ...}
    tâm subpixel từng chấm → gán (cột, hàng) → fit phép đồng dạng → phần dư
dinh_vi(anh, cfg) -> (tam_xy_px, goc_deg) | None
    None khi: không có contour đủ lớn, hoặc vật chạm mép ảnh
tim_canh(anh, tam, huong, dai, cuc, ...) -> (x, y) | None
    caliper: lấy mẫu dọc đoạn quét, gradient có cực tính, đỉnh subpixel
do_pixel(anh, vi_tri, cfg, mm_px) -> {"L", "P", "D1", "D2"} (pixel) | None
    mọi đường quét đặt theo hệ tọa độ của vật (tâm + góc), cửa sổ ±1,5 mm quanh danh định
BoDo.do(anh) -> {"ket_qua": 1|2|3, "ma_loi": bitmask, "mm": {...}, "ms": {...}}
    mm = px × mm_moi_px + bù_biên[kích thước]; NG nếu |mm − danh định| > dung sai

Ba điểm khó nhất, nên thử riêng từng điểm trên ảnh có đáp án:

  1. Gán chỉ số lưới cho từng chấm khi tấm lưới hơi xoay: ước lượng góc từ vector tới láng giềng gần nhất, xoay ngược rồi làm tròn.
  2. Góc của vật: góc của minAreaRect có quy ước thay đổi giữa các phiên bản OpenCV; tính lại góc của cạnh dài từ boxPoints.
  3. Độ chính xác subpixel của đường quét quanh lỗ: 72 tia quanh một lỗ có cùng "pha" lấy mẫu, nên sai số nội suy không triệt tiêu nhau mà cộng lại thành sai số hệ thống. Mục 7.2 cho bạn thấy con số.

6.3. Lời giải phần lõi

Xem lời giải phần lõi (5 phần, chạy nối tiếp sau khối 6.1 trong cùng một file hoặc notebook)

Phần 1: cấu hình và bảng địa chỉ. Lần chạy đầu tạo cau_hinh.toml mẫu; các lần sau đọc file bạn đã sửa. (Thư mục chạy phải chưa có file này nếu muốn ra đúng kết quả như bài.)

# ===== cau_hinh.py — đọc cấu hình TOML (tomllib có sẵn từ Python 3.11) =====
import tomllib
from pathlib import Path

CAU_HINH_MAC_DINH = """
[tram]
ten = "DK-01 do tam de"
ma_sp = 101

[hieu_chuan]
buoc_luoi_mm = 4.0
so_cot = 15
so_hang = 11
phan_du_toi_da_px = 0.10      # phần dư lớn nhất cho phép khi fit lưới
file = "hieu_chuan.json"       # kết quả: mm/px + bù biên, có ngày hiệu chuẩn

[dinh_vi]
dien_tich_min_px = 100000      # contour nhỏ hơn: không có sản phẩm

[caliper]
so_duong_L = 25                # số đường quét mỗi đầu để đo chiều dài
khoang_quet_L_mm = 24.0        # trải đều trên đoạn ±12 mm quanh trục, tránh góc bo
so_tia_lo = 72                 # số tia quét quanh mỗi lỗ
nua_cua_so_mm = 1.5            # cửa sổ tìm cạnh: ±1,5 mm quanh vị trí danh định
rong_dai_px = 5                # lấy trung bình 5 đường song song (projection) để giảm nhiễu
nguong_canh = 15               # gradient tối thiểu (mức xám/px)
noi_suy = "gauss"              # nội suy đỉnh gradient: "gauss" hoặc "parabol"
buoc_quet_px = 0.5             # bước lấy mẫu dọc đường quét

[kich_thuoc]                   # danh định, dung sai ± (mm)
L  = [60.00, 0.10]             # chiều dài tấm
P  = [40.00, 0.05]             # khoảng cách tâm hai lỗ
D1 = [8.00, 0.05]              # đường kính lỗ trái
D2 = [8.00, 0.05]              # đường kính lỗ phải

[plc]
host = "127.0.0.1"
port = 5031
device_id = 1

[luu_tru]
csdl = "ket_qua.db"
thu_muc_anh_ng = "anh_ng"
"""

p = Path("cau_hinh.toml")
if not p.exists():                           # lần chạy đầu: tạo file mẫu để sửa
    p.write_text(CAU_HINH_MAC_DINH, encoding="utf-8")
with open(p, "rb") as f:
    CFG = tomllib.load(f)

# Bảng địa chỉ Modbus (địa chỉ giao thức, từ 0) — "hợp đồng" với PLC, ghi trong đặc tả
TRIGGER, READY, BUSY, DONE = 0, 1, 2, 3                 # coil
HR_MA_SP, HR_CHU_KY = 0, 1                              # PLC ghi
HR_KQ = 10    # 10: kết quả (1 OK, 2 NG, 3 LỖI), 11: chu kỳ trả lại, 12: mã lỗi (bit), 13: dự phòng
HR_SO = 14    # 14..21: L, P, D1, D2 dạng float32 (2 thanh ghi mỗi số, thứ tự ABCD)
KQ_OK, KQ_NG, KQ_LOI = 1, 2, 3
BIT_LOI = {"L": 1, "P": 2, "D1": 4, "D2": 8, "dinh_vi": 256}

print("Trạm:", CFG["tram"]["ten"], "| kích thước kiểm:", {k: tuple(v) for k, v in CFG["kich_thuoc"].items()})
Trạm: DK-01 do tam de | kích thước kiểm: {'L': (60.0, 0.1), 'P': (40.0, 0.05), 'D1': (8.0, 0.05), 'D2': (8.0, 0.05)}

Phần 2: hiệu chuẩn tỉ lệ. Tâm chấm tính bằng trọng tâm có trọng số mức xám (chính xác hơn nhiều so với trọng tâm của ảnh nhị phân). Phép đồng dạng (tỉ lệ, xoay, tịnh tiến) có 4 ẩn, tuyến tính, giải bằng bình phương tối thiểu. Fit thêm một phép affine tự do để so tỉ lệ hai trục: nếu khác 1 đáng kể, camera nhìn nghiêng hoặc pixel không vuông, và quy đổi bằng một hệ số không còn đủ.

# ===== hieu_chuan.py — phần 1: tỉ lệ mm/px từ tấm lưới chấm =====
import json
import math

import cv2
import numpy as np


def tam_cham(anh8):
    """Tâm subpixel các chấm tối: lọc blob theo diện tích, rồi trọng tâm có trọng số mức xám."""
    _, bw = cv2.threshold(anh8, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    n, _, stats, _ = cv2.connectedComponentsWithStats(bw)
    nen = float(np.median(anh8))                         # mức sáng nền (chiếm đa số ảnh)
    ds = []
    dt_trung_vi = np.median(stats[1:, cv2.CC_STAT_AREA])
    for x, y, w, h, dt in stats[1:]:
        cham_mep = x <= 1 or y <= 1 or x + w >= anh8.shape[1] - 1 or y + h >= anh8.shape[0] - 1
        if cham_mep or not (0.5 * dt_trung_vi < dt < 1.5 * dt_trung_vi):
            continue                                     # bỏ chấm cắt mép, bụi, vết bẩn
        x0, y0, x1, y1 = x - 3, y - 3, x + w + 3, y + h + 3
        o = anh8[y0:y1, x0:x1].astype(np.float64)
        wgt = np.clip(nen - o, 0, None)                  # càng tối càng nặng
        yy, xx = np.mgrid[y0:y1, x0:x1]
        ds.append(((wgt * xx).sum() / wgt.sum(), (wgt * yy).sum() / wgt.sum()))
    return np.array(ds)


def gan_chi_so(tam):
    """Gán (cột, hàng) cho từng chấm: ước lượng góc lưới từ láng giềng gần nhất rồi xoay về thẳng."""
    d = np.linalg.norm(tam[:, None] - tam[None], axis=2)
    np.fill_diagonal(d, np.inf)
    vec = tam[d.argmin(axis=1)] - tam
    ngang = vec[np.abs(vec[:, 0]) > np.abs(vec[:, 1])]
    ngang = ngang * np.sign(ngang[:, :1])                 # cùng chiều +x
    goc = np.median(np.arctan2(ngang[:, 1], ngang[:, 0]))
    c, s = math.cos(-goc), math.sin(-goc)
    xoay = tam @ np.array([[c, s], [-s, c]])              # xoay ngược góc lưới
    buoc = np.median(d.min(axis=1))
    ij = np.round((xoay - xoay.min(axis=0)) / buoc).astype(int)
    return ij, buoc


def hieu_chuan(anh8, cfg):
    hc = cfg["hieu_chuan"]
    tam = tam_cham(anh8)
    ij, _ = gan_chi_so(tam)
    if len(tam) != hc["so_cot"] * hc["so_hang"] or len({tuple(r) for r in ij}) != len(tam):
        raise ValueError(f"Lưới chấm không đầy đủ: thấy {len(tam)} chấm")
    XY = ij * hc["buoc_luoi_mm"]                          # tọa độ mm của chấm trên tấm chuẩn
    # Phép đồng dạng: x = a·X − b·Y + tx ; y = b·X + a·Y + ty  (tuyến tính theo a, b, tx, ty)
    A = np.zeros((2 * len(XY), 4))
    A[0::2] = np.column_stack([XY[:, 0], -XY[:, 1], np.ones(len(XY)), np.zeros(len(XY))])
    A[1::2] = np.column_stack([XY[:, 1], XY[:, 0], np.zeros(len(XY)), np.ones(len(XY))])
    a, b, tx, ty = np.linalg.lstsq(A, tam.ravel(), rcond=None)[0]
    du = (A @ [a, b, tx, ty] - tam.ravel()).reshape(-1, 2)
    du_max = float(np.linalg.norm(du, axis=1).max())
    px_moi_mm = math.hypot(a, b)
    # Kiểm tra thêm: fit affine tự do, so tỉ lệ hai trục (khác nhau = pixel không vuông / camera nghiêng)
    Aa = np.column_stack([XY, np.ones(len(XY))])
    M = np.linalg.lstsq(Aa, tam, rcond=None)[0]
    ti_le_truc = np.linalg.norm(M[0]) / np.linalg.norm(M[1])
    return {"mm_moi_px": 1 / px_moi_mm, "goc_luoi_deg": math.degrees(math.atan2(b, a)),
            "phan_du_max_px": du_max, "ti_le_hai_truc": float(ti_le_truc), "so_cham": len(tam)}


rng = np.random.default_rng(1)
anh_luoi = ve_luoi_cham(rng)
kq_hc = hieu_chuan(anh_luoi, CFG)
print(f"{kq_hc['so_cham']} chấm | mm/px = {kq_hc['mm_moi_px']:.6f} (đáp án ẩn {K_THAT}) | "
      f"góc lưới {kq_hc['goc_luoi_deg']:.3f}°")
print(f"phần dư lớn nhất {kq_hc['phan_du_max_px']:.3f} px (cho phép {CFG['hieu_chuan']['phan_du_toi_da_px']}) | "
      f"tỉ lệ hai trục {kq_hc['ti_le_hai_truc']:.5f}")
print("Sai số tỉ lệ:", f"{(kq_hc['mm_moi_px'] / K_THAT - 1) * 1e6:+.0f} ppm",
      f"→ trên 60 mm: {(kq_hc['mm_moi_px'] / K_THAT - 1) * 60 * 1000:+.2f} µm")
165 chấm | mm/px = 0.085000 (đáp án ẩn 0.085) | góc lưới 1.200°
phần dư lớn nhất 0.017 px (cho phép 0.1) | tỉ lệ hai trục 1.00000
Sai số tỉ lệ: -3 ppm → trên 60 mm: -0.16 µm

Sai số tỉ lệ vài ppm, phần dư 0,017 px: ảnh tổng hợp không có méo ống kính. Trên camera thật, phần dư thường lớn hơn nhiều; nếu vượt ngưỡng, đó là dấu hiệu cần hiệu chuẩn đầy đủ có mô hình méo (chặng Calibration), hoặc ống kính telecentric.

Phần 3: định vị và đo bằng pixel. Caliper lấy trung bình 5 đường song song, lấy mẫu mỗi 0,5 px, và nội suy đỉnh gradient bằng parabol qua logarit của ba giá trị gradient (nội suy Gauss). Lý do chọn như vậy nằm ở mục 7.2.

# ===== do_luong.py — định vị + caliper subpixel + fit (đơn vị pixel) =====
import math

import cv2
import numpy as np


def tim_canh(anh, tam, huong, dai, cuc, rong=5, nguong=15, buoc=0.5, noi_suy="gauss"):
    """Caliper: quét đoạn dài `dai` px qua `tam` theo `huong` (bước `buoc` px), lấy trung bình `rong`
    đường song song. cuc: +1 tối→sáng, -1 sáng→tối theo chiều quét. Trả về (x, y) subpixel hoặc None."""
    hx, hy = np.asarray(huong, np.float64) / np.hypot(*huong)
    t = np.arange(-dai / 2, dai / 2 + buoc / 2, buoc)
    w = np.arange(rong) - (rong - 1) / 2
    mx = (tam[0] + t[None, :] * hx - w[:, None] * hy).astype(np.float32)
    my = (tam[1] + t[None, :] * hy + w[:, None] * hx).astype(np.float32)
    p = cv2.remap(anh, mx, my, cv2.INTER_CUBIC).mean(axis=0).astype(np.float64)
    g = np.zeros_like(p)
    g[1:-1] = (p[2:] - p[:-2]) / (2 * buoc) * cuc        # gradient theo mức xám/px
    k = int(np.argmax(g))
    if g[k] < nguong or k < 2 or k > len(g) - 3:
        return None
    a, b, c = g[k - 1:k + 2]
    if noi_suy == "gauss":          # parabol qua LOG của 3 giá trị gradient: đúng hẳn với đỉnh dạng Gauss
        a, b, c = np.log(np.maximum([a, b, c], 1e-6))
    s = t[k] + buoc * 0.5 * (a - c) / (a - 2 * b + c)
    return tam[0] + s * hx, tam[1] + s * hy


def fit_duong(P):
    P = np.asarray(P, np.float64)
    c = P.mean(axis=0)
    v = np.linalg.svd(P - c)[2][0]
    return c, v, np.array([-v[1], v[0]])


def fit_tron_kasa(P):
    P = np.asarray(P, np.float64)
    x, y = P[:, 0], P[:, 1]
    A = np.column_stack([x, y, np.ones_like(x)])
    D, E, F = np.linalg.lstsq(A, -(x ** 2 + y ** 2), rcond=None)[0]
    a, b = -D / 2, -E / 2
    return a, b, math.sqrt(a * a + b * b - F)


def fit_tron_ben(P, lap=2, k=3.0):
    """Kasa + loại điểm có phần dư > k·MAD (ba via, bụi bám mép lỗ), lặp `lap` lần."""
    P = np.asarray(P, np.float64)
    for _ in range(lap):
        a, b, r = fit_tron_kasa(P)
        du = np.hypot(P[:, 0] - a, P[:, 1] - b) - r
        mad = np.median(np.abs(du - np.median(du))) + 1e-9
        giu = np.abs(du) <= k * 1.4826 * mad
        if giu.all() or giu.sum() < 10:
            break
        P = P[giu]
    return (*fit_tron_kasa(P), len(P))


def dinh_vi(anh8, cfg):
    """Tìm tấm đế: contour lớn nhất → minAreaRect → góc CẠNH DÀI tính từ boxPoints."""
    _, bw = cv2.threshold(anh8, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    cs, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
    if not cs:
        return None
    c = max(cs, key=cv2.contourArea)
    x, y, w, h = cv2.boundingRect(c)
    if cv2.contourArea(c) < cfg["dinh_vi"]["dien_tich_min_px"]:
        return None                                       # không có sản phẩm / sản phẩm vỡ
    if x <= 0 or y <= 0 or x + w >= anh8.shape[1] or y + h >= anh8.shape[0]:
        return None                                       # chạm mép ảnh: không đo được đủ
    hop = cv2.boxPoints(cv2.minAreaRect(c))
    canh = [hop[(i + 1) % 4] - hop[i] for i in range(4)]
    e = max(canh, key=np.linalg.norm)
    goc = math.degrees(math.atan2(e[1], e[0]))
    goc = (goc + 90) % 180 - 90                           # đưa về [-90°, 90°)
    M = cv2.moments(c)
    return np.array([M["m10"] / M["m00"], M["m01"] / M["m00"]]), goc


def do_pixel(anh8, vi_tri, cfg, mm_px):
    """Đo L, P, D1, D2 (pixel). Mọi công cụ đặt theo hệ tọa độ của sản phẩm (dời theo vi_tri)."""
    cal, kt = cfg["caliper"], cfg["kich_thuoc"]
    anh = anh8.astype(np.float32)
    tam, goc = vi_tri
    a = math.radians(goc)
    u, v = np.array([math.cos(a), math.sin(a)]), np.array([-math.sin(a), math.cos(a)])
    cua_so = 2 * cal["nua_cua_so_mm"] / mm_px
    opt = dict(rong=cal["rong_dai_px"], nguong=cal["nguong_canh"], noi_suy=cal["noi_suy"],
               buoc=cal["buoc_quet_px"])

    # Chiều dài: hai dãy caliper ở hai đầu, quét theo +u
    nua_L = kt["L"][0] / 2 / mm_px
    lech = np.linspace(-1, 1, cal["so_duong_L"]) * cal["khoang_quet_L_mm"] / 2 / mm_px
    trai = [tim_canh(anh, tam - nua_L * u + s * v, u, cua_so, cuc=-1, **opt) for s in lech]
    phai = [tim_canh(anh, tam + nua_L * u + s * v, u, cua_so, cuc=+1, **opt) for s in lech]
    trai = [q for q in trai if q is not None]
    phai = [q for q in phai if q is not None]
    if min(len(trai), len(phai)) < 0.8 * len(lech):
        return None                                       # mất quá nhiều điểm cạnh
    cT, vT, nT = fit_duong(trai)
    cP, vP, nP = fit_duong(phai)
    n = nT if np.dot(nT, nP) > 0 else -nT
    n = (n + nP) / np.linalg.norm(n + nP)
    L = abs(np.dot(cP - cT, n))

    # Hai lỗ: tia quét từ trong lỗ (sáng) ra vật liệu (tối) → cực tính -1
    lo = []
    for dau in (-1, +1):                                  # -1: lỗ trái, +1: lỗ phải
        c0 = tam + dau * kt["P"][0] / 2 / mm_px * u
        r0 = kt["D1" if dau < 0 else "D2"][0] / 2 / mm_px
        diem = []
        for t in np.radians(np.arange(0, 360, 360 / cal["so_tia_lo"])):
            h = np.array([math.cos(t), math.sin(t)])
            q = tim_canh(anh, c0 + r0 * h, h, cua_so, cuc=-1, **opt)
            if q is not None:
                diem.append(q)
        if len(diem) < 0.8 * cal["so_tia_lo"]:
            return None
        lo.append(fit_tron_ben(diem))
    (x1, y1, r1, _), (x2, y2, r2, _) = lo
    return {"L": L, "P": math.hypot(x2 - x1, y2 - y1), "D1": 2 * r1, "D2": 2 * r2,
            "tam_lo": ((x1, y1), (x2, y2))}


# Thử trên một ảnh có đáp án: tâm (41,0; 30,0) mm, góc 2,5°
rng = np.random.default_rng(2)
anh = ve_chi_tiet(rng, tam=(41.0, 30.0), goc_deg=2.5)
vt = dinh_vi(anh, CFG)
print(f"Định vị: tâm ({vt[0][0]:.2f}, {vt[0][1]:.2f}) px, góc {vt[1]:.3f}° "
      f"(đáp án ({41.0 / K_THAT:.2f}, {30.0 / K_THAT:.2f}) px, 2.500°)")
kq_px = do_pixel(anh, vt, CFG, kq_hc["mm_moi_px"])
that_px = {"L": 60 / K_THAT, "P": 40 / K_THAT, "D1": 8 / K_THAT, "D2": 8 / K_THAT}
for k in ("L", "P", "D1", "D2"):
    print(f"{k:2s}: đo {kq_px[k]:8.3f} px | thật {that_px[k]:8.3f} px | lệch {kq_px[k] - that_px[k]:+.3f} px")
Định vị: tâm (482.35, 352.94) px, góc 2.499° (đáp án (482.35, 352.94) px, 2.500°)
L : đo  705.577 px | thật  705.882 px | lệch -0.306 px
P : đo  470.580 px | thật  470.588 px | lệch -0.008 px
D1: đo   94.359 px | thật   94.118 px | lệch +0.242 px
D2: đo   94.361 px | thật   94.118 px | lệch +0.244 px

Định vị đúng tới 0,01 px và 0,001°. Khoảng cách tâm lỗ P gần như không lệch, nhưng L ngắn đi khoảng 0,3 px và hai đường kính lớn thêm khoảng 0,24 px: đó là sai lệch biên do ánh sáng loe (0,15 px mỗi cạnh, tức 0,3 px cho mỗi kích thước cạnh-tới-cạnh), với đường kính bớt đi một phần nhỏ do việc lấy trung bình 5 đường song song trên cạnh cong. Không thuật toán tìm cạnh nào tự biết được điều này; phải bù bằng vật chuẩn.

Phần 4: bù sai lệch biên bằng chi tiết chuẩn. Đo một tấm đã có phiếu đo CMM 10 lần, mỗi lần nạp lại, lấy chênh lệch trung bình làm giá trị bù cho từng kích thước.

# ===== hieu_chuan.py — phần 2: bù sai lệch biên bằng chi tiết chuẩn (master) =====
import json
from datetime import date

import numpy as np

# Chi tiết chuẩn: một tấm đế đã đo trên máy CMM (giá trị in trên phiếu đo)
MASTER_CMM = {"L": 60.012, "P": 39.996, "D1": 8.004, "D2": 7.998}
KT = ("L", "P", "D1", "D2")

rng = np.random.default_rng(3)
mm_px = kq_hc["mm_moi_px"]
do_master = []
for _ in range(10):                                   # 10 lần nạp lại chi tiết chuẩn
    anh = chup_ngau_nhien(rng, MASTER_CMM)
    r = do_pixel(anh, dinh_vi(anh, CFG), CFG, mm_px)
    do_master.append([r[k] * mm_px for k in KT])
tb = np.mean(do_master, axis=0)

# Bù biên chỉ cho kích thước CẠNH-tới-CẠNH (L, D). P là khoảng cách TÂM-tới-TÂM: biên loe đều
# không làm dời tâm, nên không bù (bù bằng trung bình 10 lần đo chỉ cộng thêm nhiễu).
bu = {k: (round(float(MASTER_CMM[k] - tb[i]), 4) if k != "P" else 0.0) for i, k in enumerate(KT)}
for i, k in enumerate(KT):
    print(f"{k:2s}: CMM {MASTER_CMM[k]:.3f} | đo TB {tb[i]:.4f} mm | chênh {MASTER_CMM[k] - tb[i]:+.4f}"
          f" → bù {bu[k]:+.4f} mm")

HIEU_CHUAN = {"mm_moi_px": kq_hc["mm_moi_px"], "phan_du_max_px": kq_hc["phan_du_max_px"], "bu_mm": bu,
              "ngay": date.today().isoformat(), "master": MASTER_CMM, "so_lan_master": len(do_master)}
with open(CFG["hieu_chuan"]["file"], "w", encoding="utf-8") as f:
    json.dump(HIEU_CHUAN, f, ensure_ascii=False, indent=2)
print("Đã lưu", CFG["hieu_chuan"]["file"])
L : CMM 60.012 | đo TB 59.9865 mm | chênh +0.0255 → bù +0.0255 mm
P : CMM 39.996 | đo TB 39.9957 mm | chênh +0.0003 → bù +0.0000 mm
D1: CMM 8.004 | đo TB 8.0247 mm | chênh -0.0207 → bù -0.0207 mm
D2: CMM 7.998 | đo TB 8.0185 mm | chênh -0.0205 → bù -0.0205 mm
Đã lưu hieu_chuan.json

Chênh lệch của L dương (đo ngắn hơn thật), của D âm (đo lớn hơn thật), đúng chiều của ánh sáng loe làm vật liệu tối "co lại". P chênh 0,3 µm, nhỏ hơn độ lặp lại, nên không bù: bù một con số chỉ chứa nhiễu thì chỉ thêm nhiễu.

Phần 5: một chu kỳ đo hoàn chỉnh. Lớp BoDo đọc file hiệu chuẩn, đo, quy đổi, bù và phán xét; trả về cả thời gian từng bước.

# ===== tram_do.py — phần lõi: một chu kỳ đo hoàn chỉnh (ảnh → mm → OK/NG) =====
import json
import time

import numpy as np


class BoDo:
    """Gói định vị + đo + quy đổi + phán xét. Không biết gì về PLC hay ổ đĩa."""

    def __init__(self, cfg, file_hieu_chuan):
        self.cfg = cfg
        with open(file_hieu_chuan, encoding="utf-8") as f:
            self.hc = json.load(f)
        self.mm_px = self.hc["mm_moi_px"]

    def do(self, anh8):
        t = [time.perf_counter()]
        vt = dinh_vi(anh8, self.cfg)
        t.append(time.perf_counter())
        px = do_pixel(anh8, vt, self.cfg, self.mm_px) if vt is not None else None
        t.append(time.perf_counter())
        if px is None:
            return {"ket_qua": KQ_LOI, "ma_loi": BIT_LOI["dinh_vi"], "mm": None,
                    "ms": {"dinh_vi": (t[1] - t[0]) * 1e3, "do": (t[2] - t[1]) * 1e3}}
        mm, ma_loi = {}, 0
        for k, (danh_dinh, dung_sai) in self.cfg["kich_thuoc"].items():
            mm[k] = px[k] * self.mm_px + self.hc["bu_mm"][k]
            if abs(mm[k] - danh_dinh) > dung_sai:
                ma_loi |= BIT_LOI[k]
        t.append(time.perf_counter())
        return {"ket_qua": KQ_OK if ma_loi == 0 else KQ_NG, "ma_loi": ma_loi, "mm": mm, "goc": vt[1],
                "ms": {"dinh_vi": (t[1] - t[0]) * 1e3, "do": (t[2] - t[1]) * 1e3, "xet": (t[3] - t[2]) * 1e3}}


bo_do = BoDo(CFG, CFG["hieu_chuan"]["file"])
DD = {k: v[0] for k, v in CFG["kich_thuoc"].items()}        # kích thước danh định
rng = np.random.default_rng(4)
thu = [("tấm tốt", {"L": 60.03, "P": 40.01, "D1": 8.01, "D2": 7.99}),
       ("lỗ phải to", {"L": 59.98, "P": 40.00, "D1": 8.00, "D2": 8.08}),
       ("dài quá", {"L": 60.14, "P": 39.99, "D1": 8.00, "D2": 8.00})]
for ten, that in thu:
    kq = bo_do.do(chup_ngau_nhien(rng, that))
    lech = "  ".join(f"{k}={kq['mm'][k]:.4f}({(kq['mm'][k] - that[k]) * 1000:+.1f}µm)" for k in KT)
    loi = [k for k, b in BIT_LOI.items() if kq["ma_loi"] & b]
    print(f"{ten:11s}: {'OK' if kq['ket_qua'] == KQ_OK else 'NG'} {str(loi):12s} {lech}")
print("Thời gian (ms, tùy máy):", {k: round(v, 1) for k, v in kq["ms"].items()})
tấm tốt    : OK []           L=60.0313(+1.3µm)  P=40.0087(-1.3µm)  D1=8.0090(-1.0µm)  D2=7.9903(+0.3µm)
lỗ phải to : NG ['D2']       L=59.9806(+0.6µm)  P=39.9986(-1.4µm)  D1=7.9998(-0.2µm)  D2=8.0808(+0.8µm)
dài quá    : NG ['L']        L=60.1396(-0.4µm)  P=39.9897(-0.3µm)  D1=7.9984(-1.6µm)  D2=7.9997(-0.3µm)
Thời gian (ms, tùy máy): {'dinh_vi': 1.4, 'do': 17.9, 'xet': 0.0}

Sai số sau bù còn khoảng ±2 µm trên ảnh có đáp án, nhỏ hơn 1/20 dung sai hẹp nhất. Dòng thời gian tùy máy.

7. Kiểm thử và nghiệm thu

Kế hoạch thử gồm bốn kịch bản, tất cả là script chạy lại được (chạy nối tiếp sau lời giải mục 6.3). Đây chính là phần nhà tuyển dụng muốn thấy nhất: bạn không chỉ nói "trạm đo chính xác", bạn chứng minh bằng số liệu.

Kịch bảnMục đíchYêu cầu liên quan
7.1 Độ lặp lại 3 × 30σ lặp lại, %GRR, bias so với CMMR2, R3, R7
7.2 Độ tuyến tínhSai số có thay đổi theo kích thước khôngR3 (trên cả dải dung sai)
7.3 Bộ mẫu vàngEscape, overkill, đúng mã lỗi, băng tải trốngR4, R5, R6
7.4 Tích hợp PLC + lưu trữBắt tay, dữ liệu PLC khớp CSDL, ảnh NGR8, R9
7.5 Biên bảnGom mọi kết quả thành bảng ĐẠT/KHÔNG ĐẠTR1–R9

7.1. Độ lặp lại: 3 chi tiết × 30 lần nạp lại

Ba chi tiết được chọn trải dải dung sai: một gần danh định, một gần giới hạn trên, một gần giới hạn dưới. Mỗi lần đo là một lần "nạp lại": vị trí, góc, độ sáng và nhiễu đều mới. σ lặp lại tính gộp (pooled) từ phương sai của từng chi tiết, %GRR = 6σ/T như bài nghiệm thu FAT/SAT.

# ===== danh_gia_lap_lai.py — nghiên cứu độ lặp lại: 3 chi tiết × 30 lần nạp lại =====
import csv

import numpy as np

# 3 chi tiết đã đo CMM: gần danh định, gần giới hạn trên, gần giới hạn dưới
CHI_TIET = {"A": {"L": 60.021, "P": 40.008, "D1": 8.012, "D2": 7.991},
            "B": {"L": 60.078, "P": 40.035, "D1": 8.036, "D2": 8.030},
            "C": {"L": 59.931, "P": 39.962, "D1": 7.968, "D2": 7.965}}
SO_LAN = 30

rng = np.random.default_rng(5)
do_lap, t_ms = {}, []
with open("lap_lai.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["chi_tiet", "lan"] + list(KT))
    for ten, cmm in CHI_TIET.items():
        hang = []
        for lan in range(1, SO_LAN + 1):
            kq = bo_do.do(chup_ngau_nhien(rng, cmm))
            hang.append([kq["mm"][k] for k in KT])
            t_ms.append(sum(kq["ms"].values()))
            w.writerow([ten, lan] + [f"{kq['mm'][k]:.5f}" for k in KT])
        do_lap[ten] = np.array(hang)

KQ_LAP = {}
print("KT | T (mm) | σ lặp lại (µm) | 6σ (µm) | %GRR | bias A/B/C (µm)      | kết luận")
for i, k in enumerate(KT):
    T = 2 * CFG["kich_thuoc"][k][1]
    s = np.sqrt(np.mean([do_lap[c][:, i].var(ddof=1) for c in CHI_TIET]))   # σ gộp của 3 chi tiết
    bias = [do_lap[c][:, i].mean() - CHI_TIET[c][k] for c in CHI_TIET]
    grr = 6 * s / T * 100
    dat = grr <= 10 and max(map(abs, bias)) <= 0.1 * T
    KQ_LAP[k] = {"sigma_um": s * 1000, "grr": grr, "bias_max_um": max(map(abs, bias)) * 1000, "dat": dat}
    b = "/".join(f"{x * 1000:+.1f}" for x in bias)
    print(f"{k:2s} | {T:.2f}   | {s * 1000:14.2f} | {6 * s * 1000:7.1f} | {grr:4.1f}% | {b:20s} | "
          f"{'ĐẠT' if dat else 'KHÔNG ĐẠT'}")
print(f"Đã ghi lap_lai.csv ({len(CHI_TIET) * SO_LAN} lần đo)")
KT | T (mm) | σ lặp lại (µm) | 6σ (µm) | %GRR | bias A/B/C (µm)      | kết luận
L  | 0.20   |           1.43 |     8.6 |  4.3% | +0.3/-0.1/-0.2       | ĐẠT
P  | 0.10   |           0.87 |     5.2 |  5.2% | -0.4/+0.2/-0.0       | ĐẠT
D1 | 0.10   |           0.88 |     5.3 |  5.3% | +0.1/-0.1/-0.3       | ĐẠT
D2 | 0.10   |           1.02 |     6.1 |  6.1% | +0.1/+0.0/-0.0       | ĐẠT
Đã ghi lap_lai.csv (90 lần đo)

Mọi kích thước có %GRR 4–6%, bias dưới 0,5 µm. L có σ lớn nhất về tuyệt đối (mỗi đầu chỉ có 25 điểm cạnh, so với 72 điểm quanh mỗi lỗ) nhưng dải dung sai của L cũng gấp đôi. Nhắc lại: đây là giới hạn của thuật toán trên ảnh lý tưởng. Trên trạm thật, rung, tấm cong và bụi thường làm σ tăng vài lần; nếu khi đó %GRR vượt 10%, cách hiệu quả nhất thường là cải thiện cơ khí và quang học (đồ gá, telecentric, độ phân giải), không phải thuật toán.

7.2. Độ tuyến tính: cái bẫy "pixel-locking"

Bù biên bằng master chỉ đúng tại kích thước của master. Nếu sai số thay đổi theo kích thước, chi tiết gần giới hạn dung sai, đúng chỗ quyết định OK/NG, sẽ bị đo sai. Thử với các lỗ Ø7,92 tới Ø8,08 mm, so caliper "sách giáo khoa" (parabol trên gradient, bước 1 px) với caliper của lời giải (Gauss, bước 0,5 px):

# ===== danh_gia_tuyen_tinh.py — sai số có đổi theo kích thước không? (caliper cũ so với caliper mới) =====
import copy

import numpy as np

CO = [7.92, 7.96, 8.00, 8.04, 8.08]                   # đường kính lỗ thật (mm), trải hết dải dung sai
for noi_suy, buoc in (("parabol", 1.0), ("gauss", 0.5)):
    cfg2 = copy.deepcopy(CFG)
    cfg2["caliper"].update(noi_suy=noi_suy, buoc_quet_px=buoc)
    bd = BoDo(cfg2, CFG["hieu_chuan"]["file"])
    rng = np.random.default_rng(9)                    # cùng bộ ảnh cho cả hai cách
    sai = []
    for d in CO:
        e = [bd.do(chup_ngau_nhien(rng, {**DD, "D1": d, "D2": d}))["mm"]["D2"] - d for _ in range(6)]
        sai.append(np.mean(e) * 1000)
    tuong_doi = np.array(sai) - sai[CO.index(8.00)]   # so với sai số tại danh định (đã bù bằng master)
    print(f"{noi_suy:7s} bước {buoc} px | sai số D2 so với tại 8,00 mm (µm):",
          " ".join(f"{x:+5.1f}" for x in tuong_doi), f"| dao động {np.ptp(tuong_doi):.1f} µm")
parabol bước 1.0 px | sai số D2 so với tại 8,00 mm (µm): -12.1  -3.4  +0.0  -6.1 -11.3 | dao động 12.1 µm
gauss   bước 0.5 px | sai số D2 so với tại 8,00 mm (µm):  -0.2  +0.1  +0.0  -0.1  +1.3 | dao động 1.5 µm

Caliper bước 1 px có sai số dao động 12 µm theo kích thước, tức 12% dải dung sai, trong khi độ lặp lại chỉ khoảng 1 µm: một sai số hệ thống mà nghiên cứu lặp lại không bao giờ phát hiện. Nguyên nhân: cạnh trong ảnh rất sắc (độ mờ khoảng 0,8 px), đỉnh gradient chỉ rộng vài mẫu, và parabol qua 3 điểm cho sai số phụ thuộc vào vị trí cạnh so với các điểm lấy mẫu (hiện tượng thường gọi là pixel-locking). Với cạnh thẳng của tấm xoay, vị trí đó thay đổi dọc cạnh nên sai số tự trung bình đi; nhưng 72 tia quanh một lỗ đều bắt đầu ở cùng bán kính danh định, cùng "pha", nên sai số cộng dồn. Lấy mẫu dày hơn và nội suy kiểu Gauss đưa dao động về khoảng 1,5 µm, ngang mức nhiễu của phép thử 6 lần đo.

Bài học đo lườngLuôn thử tuyến tính trên cả dải dung sai, không chỉ thử lặp lại trên một chi tiết. Phần mềm thương mại xử lý chuyện này bên trong công cụ caliper; khi tự viết bằng OpenCV, trách nhiệm là của bạn.

7.3. Bộ mẫu vàng: escape, overkill, mã lỗi

Mười mẫu: năm mẫu OK (có mẫu gần giới hạn nhưng còn cách giới hạn ít nhất 20 µm) và năm mẫu NG, mỗi mẫu sai đúng một kích thước. Mỗi mẫu chạy 6 lần ở vị trí khác nhau. Thêm một ảnh băng tải trống: trạm phải báo LỖI chứ không được báo OK.

# ===== danh_gia_golden.py — bộ mẫu vàng: escape / overkill / đúng mã lỗi =====
import numpy as np

MAU_VANG = [  # (mã mẫu, kích thước thật, lỗi đúng)
    ("OK-1", DD, set()),
    ("OK-2", {**DD, "L": 60.07, "P": 40.03}, set()),
    ("OK-3", {**DD, "L": 59.93, "D1": 7.97}, set()),
    ("OK-4", {**DD, "D1": 8.03, "D2": 7.97}, set()),
    ("OK-5", {**DD, "P": 39.97, "D2": 8.03}, set()),
    ("NG-L+", {**DD, "L": 60.13}, {"L"}),
    ("NG-L-", {**DD, "L": 59.87}, {"L"}),
    ("NG-P", {**DD, "P": 40.07}, {"P"}),
    ("NG-D1", {**DD, "D1": 7.93}, {"D1"}),
    ("NG-D2", {**DD, "D2": 8.07}, {"D2"}),
]
SO_LAN_MAU = 6

rng = np.random.default_rng(6)
escape = overkill = sai_ma = 0
so_ok = so_ng = 0
for ma, that, loi_dung in MAU_VANG:
    for _ in range(SO_LAN_MAU):
        kq = bo_do.do(chup_ngau_nhien(rng, that))
        loi = {k for k in KT if kq["ma_loi"] & BIT_LOI[k]}
        if loi_dung:
            so_ng += 1
            escape += kq["ket_qua"] == KQ_OK              # mẫu NG bị cho qua: nguy hiểm nhất
            sai_ma += kq["ket_qua"] == KQ_NG and loi != loi_dung
        else:
            so_ok += 1
            overkill += kq["ket_qua"] != KQ_OK            # mẫu OK bị loại oan

# Băng tải trống: phải báo LỖI định vị, không được báo OK
anh_trong = np.clip(np.random.default_rng(7).normal(215, 2.5, (H_ANH, W_ANH)), 0, 255).astype(np.uint8)
kq_trong = bo_do.do(anh_trong)

KQ_VANG = {"escape": escape, "overkill": overkill, "sai_ma": sai_ma, "so_ng": so_ng, "so_ok": so_ok,
           "trong_ok": kq_trong["ket_qua"] == KQ_LOI}
print(f"Mẫu NG: {so_ng} lần chạy → escape {escape}, sai mã lỗi {sai_ma}")
print(f"Mẫu OK: {so_ok} lần chạy → overkill {overkill}")
print("Băng tải trống →", {KQ_OK: "OK", KQ_NG: "NG", KQ_LOI: "LỖI"}[kq_trong["ket_qua"]],
      "| mã lỗi", kq_trong["ma_loi"])
Mẫu NG: 30 lần chạy → escape 0, sai mã lỗi 0
Mẫu OK: 30 lần chạy → overkill 0
Băng tải trống → LỖI | mã lỗi 256

Vì sao không có mẫu nằm sát giới hạn (ví dụ D = 8,049)? Với σ khoảng 1 µm, một mẫu cách giới hạn 1 µm sẽ bị báo NG khoảng một nửa số lần: không phải lỗi của trạm mà là bản chất của phép đo, như bài "Tiêu chí OK/NG, escape và overkill" đã phân tích. Mẫu vàng chứng minh chức năng; vùng sát giới hạn được xử lý bằng dải bảo vệ (guard band) nếu khách hàng yêu cầu.

7.4. Thử tích hợp: PLC giả, SQLite, ảnh NG

PLC giả giống bài Modbus TCP, chạy trên cổng 5031. "Chương trình PLC" đưa 12 sản phẩm qua trạm; camera giả có kịch bản mà trạm không biết: chu kỳ 4 lỗ phải to, chu kỳ 9 tấm dài, chu kỳ 11 băng tải trống. Để code gọn, vòng lặp ở đây chưa có tự kết nối lại và ghi nền; bản hoàn chỉnh của M5 nên lấy hai phần đó từ dự án trạm vision mô phỏng ở chặng PLC / Robot / MES.

# ===== tram.py — chạy trạm với PLC giả (Modbus TCP) + lưu SQLite + ảnh NG =====
import asyncio
import sqlite3
import threading
import time
from datetime import datetime
from pathlib import Path

import cv2
import numpy as np
from pymodbus.client import ModbusTcpClient
from pymodbus.server import ModbusTcpServer
from pymodbus.simulator import DataType, SimData, SimDevice


class PlcGia:
    """PLC giả như bài "Modbus TCP bằng Python với pymodbus" (pymodbus 3.15, SimData/SimDevice)."""

    def __init__(self, port):
        self.port = port

    def bat(self):
        self._da_nghe = threading.Event()
        self._thread = threading.Thread(target=lambda: asyncio.run(self._main()), daemon=True)
        self._thread.start()
        if not self._da_nghe.wait(timeout=3):
            raise RuntimeError("PLC giả không khởi động được")

    async def _main(self):
        self.loop = asyncio.get_running_loop()
        bo_nho = SimDevice(id=1, simdata=(
            [SimData(0, count=16, values=False, datatype=DataType.BITS)],
            [SimData(0, count=16, values=False, datatype=DataType.BITS)],
            [SimData(0, count=32, values=0, datatype=DataType.REGISTERS)],
            [SimData(0, count=16, values=0, datatype=DataType.REGISTERS)],
        ))
        self.server = ModbusTcpServer(bo_nho, address=("127.0.0.1", self.port))
        await self.server.serve_forever(background=True)
        self._da_nghe.set()
        await self.server.serving

    def tat(self):
        asyncio.run_coroutine_threadsafe(self.server.shutdown(), self.loop).result(timeout=3)
        self._thread.join(timeout=3)

    def _goi(self, coro):
        return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout=2)

    def doc_coil(self, dc):
        return self._goi(self.server.async_getValues(1, 1, dc, 1))[0]

    def ghi_coil(self, dc, v):
        self._goi(self.server.async_setValues(1, 5, dc, [bool(v)]))

    def doc_hr(self, dc, n=1):
        return self._goi(self.server.async_getValues(1, 3, dc, n))

    def ghi_hr(self, dc, gia_tri):
        self._goi(self.server.async_setValues(1, 16, dc, list(gia_tri)))


def cho(dieu_kien, timeout, nghi=0.005):
    han = time.monotonic() + timeout
    while time.monotonic() < han:
        if dieu_kien():
            return True
        time.sleep(nghi)
    return False


def chuong_trinh_plc(plc, so_sp, nhan):
    """Mỗi sản phẩm: chờ READY → ghi mã SP + số chu kỳ → TRIGGER → chờ DONE → đọc kết quả → tắt TRIGGER."""
    for k in range(1, so_sp + 1):
        if not cho(lambda: plc.doc_coil(READY), 3.0):
            nhan.append((k, "KHONG_READY", None))
            continue
        plc.ghi_hr(HR_MA_SP, [CFG["tram"]["ma_sp"], k])
        plc.ghi_coil(TRIGGER, 1)
        if cho(lambda: plc.doc_coil(DONE), 2.0):
            r = plc.doc_hr(HR_KQ, 12)                       # HR 10..21 trong một lần đọc
            so = [ModbusTcpClient.convert_from_registers(r[i:i + 2], ModbusTcpClient.DATATYPE.FLOAT32)
                  for i in (4, 6, 8, 10)]
            nhan.append((k, r[0] if r[1] == k else "LECH_CHU_KY", (r[2], so)))
        else:
            nhan.append((k, "TIMEOUT", None))
        plc.ghi_coil(TRIGGER, 0)
        cho(lambda: not plc.doc_coil(DONE), 2.0)


SCHEMA = """CREATE TABLE IF NOT EXISTS do_kich_thuoc (
    id INTEGER PRIMARY KEY, thoi_gian TEXT NOT NULL, chu_ky INTEGER, ma_sp INTEGER,
    ket_qua TEXT NOT NULL CHECK (ket_qua IN ('OK', 'NG', 'LOI')), ma_loi INTEGER,
    L REAL, P REAL, D1 REAL, D2 REAL, tg_ms REAL, anh TEXT,
    mm_moi_px REAL, ngay_hieu_chuan TEXT)"""   # lưu cả hệ số đang dùng: truy vết khi bị khiếu nại


class CameraGia:
    """Kịch bản sản xuất (trạm KHÔNG biết): chu kỳ 4 lỗ phải to, chu kỳ 9 dài quá, chu kỳ 11 trống."""
    KICH_BAN = {4: {**DD, "D2": 8.08}, 9: {**DD, "L": 60.15}, 11: None}

    def __init__(self):
        self.rng = np.random.default_rng(8)

    def chup(self, chu_ky):
        that = self.KICH_BAN.get(chu_ky, {**DD, "L": 60.01})
        if that is None:
            return np.clip(self.rng.normal(215, 2.5, (H_ANH, W_ANH)), 0, 255).astype(np.uint8)
        return chup_ngau_nhien(self.rng, that)


def chay_tram(so_chu_ky, bo_do, camera, timeout_s=30):
    pc = CFG["plc"]
    tb = pc["device_id"]
    cli = ModbusTcpClient(pc["host"], port=pc["port"], timeout=0.5, retries=1)
    if not cli.connect():
        raise ConnectionError("Không kết nối được PLC")
    con = sqlite3.connect(CFG["luu_tru"]["csdl"])
    con.execute("PRAGMA journal_mode=WAL")
    con.execute(SCHEMA)
    thu_muc_ng = Path(CFG["luu_tru"]["thu_muc_anh_ng"])
    ten_kq = {KQ_OK: "OK", KQ_NG: "NG", KQ_LOI: "LOI"}
    cli.write_coils(READY, [True, False, False], device_id=tb)       # READY, BUSY, DONE
    trigger_truoc, xong, han = False, 0, time.monotonic() + timeout_s
    cycle_ms = []
    try:
        while xong < so_chu_ky and time.monotonic() < han:
            trigger = cli.read_coils(TRIGGER, count=1, device_id=tb).bits[0]
            if trigger and not trigger_truoc:                          # sườn lên
                t0 = time.perf_counter()
                cli.write_coils(READY, [False, True, False], device_id=tb)
                ma_sp, chu_ky = cli.read_holding_registers(HR_MA_SP, count=2, device_id=tb).registers
                anh = camera.chup(chu_ky)
                kq = bo_do.do(anh)
                so = [kq["mm"][k] if kq["mm"] else 0.0 for k in KT]
                regs = [kq["ket_qua"], chu_ky, kq["ma_loi"], 0]
                for x in so:
                    regs += cli.convert_to_registers(x, cli.DATATYPE.FLOAT32)
                rr = cli.write_registers(HR_KQ, regs, device_id=tb)    # 1) kết quả, một lệnh
                if rr.isError():
                    raise RuntimeError(f"PLC từ chối ghi: {rr}")
                cli.write_coils(READY, [False, False, True], device_id=tb)   # 2) rồi mới DONE
                tg = (time.perf_counter() - t0) * 1000
                cycle_ms.append(tg)
                ts = datetime.now()
                duong_dan = None
                if kq["ket_qua"] != KQ_OK:                           # lưu ảnh NG/LỖI để xem lại
                    duong_dan = f"{ts:%Y-%m-%d}/C{chu_ky:05d}_{ts:%H%M%S}_{kq['ma_loi']}.png"
                    (thu_muc_ng / duong_dan).parent.mkdir(parents=True, exist_ok=True)
                    cv2.imwrite(str(thu_muc_ng / duong_dan), anh)
                con.execute("INSERT INTO do_kich_thuoc (thoi_gian, chu_ky, ma_sp, ket_qua, ma_loi, L, P, D1, D2,"
                            " tg_ms, anh, mm_moi_px, ngay_hieu_chuan) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
                            (ts.isoformat(timespec="milliseconds"), chu_ky, ma_sp, ten_kq[kq["ket_qua"]],
                             kq["ma_loi"], *(round(x, 4) if kq["mm"] else None for x in so), round(tg, 1),
                             duong_dan, bo_do.mm_px, bo_do.hc["ngay"]))
                con.commit()
                cho(lambda: not cli.read_coils(TRIGGER, count=1, device_id=tb).bits[0], 2.0)
                cli.write_coils(READY, [True, False, False], device_id=tb)
                trigger = False
                xong += 1
            trigger_truoc = trigger
            time.sleep(0.005)
    finally:
        cli.write_coils(READY, [False, False, False], device_id=tb)  # dừng: tắt READY
        cli.close()
        con.close()
    return cycle_ms


plc = PlcGia(CFG["plc"]["port"])
plc.bat()
nhan_plc = []
t_plc = threading.Thread(target=chuong_trinh_plc, args=(plc, 12, nhan_plc), daemon=True)
t_plc.start()
try:
    cycle_ms = chay_tram(12, bo_do, CameraGia())
finally:
    t_plc.join(timeout=5)
    plc.tat()

for k, kq, chi_tiet in nhan_plc:
    if chi_tiet:
        ma_loi, so = chi_tiet
        print(f"PLC chu kỳ {k:2d}: KQ={kq} mã lỗi={ma_loi:3d} L={so[0]:.3f} P={so[1]:.3f} "
              f"D1={so[2]:.3f} D2={so[3]:.3f}")
    else:
        print(f"PLC chu kỳ {k:2d}: {kq}")
con = sqlite3.connect(CFG["luu_tru"]["csdl"])
dem_db = dict(con.execute("SELECT ket_qua, COUNT(*) FROM do_kich_thuoc GROUP BY ket_qua").fetchall())
db = con.execute("SELECT chu_ky, ket_qua, ma_loi FROM do_kich_thuoc ORDER BY chu_ky").fetchall()
anh_ng = [p for (p,) in con.execute("SELECT anh FROM do_kich_thuoc WHERE anh IS NOT NULL")]
con.close()
khop = len(nhan_plc) == 12 and all(kq == {"OK": 1, "NG": 2, "LOI": 3}[dbk] and ml == chi[0]
                                   for (k, kq, chi), (_, dbk, ml) in zip(nhan_plc, db))
KQ_PLC = {"so_chu_ky": len(nhan_plc), "khop": khop, "dem_db": dem_db,
          "anh_du": all((Path(CFG["luu_tru"]["thu_muc_anh_ng"]) / p).exists() for p in anh_ng),
          "so_anh": len(anh_ng)}
print("CSDL:", dem_db, "| ảnh NG/LỖI:", len(anh_ng), "| PLC khớp CSDL:", khop)
print(f"Thời gian trigger→DONE (tùy máy): trung vị {np.median(cycle_ms):.0f} ms, lớn nhất {max(cycle_ms):.0f} ms")
PLC chu kỳ  1: KQ=1 mã lỗi=  0 L=60.009 P=40.000 D1=7.999 D2=8.003
PLC chu kỳ  2: KQ=1 mã lỗi=  0 L=60.011 P=40.000 D1=7.999 D2=8.001
PLC chu kỳ  3: KQ=1 mã lỗi=  0 L=60.011 P=40.001 D1=7.999 D2=8.002
PLC chu kỳ  4: KQ=2 mã lỗi=  8 L=59.999 P=39.998 D1=8.001 D2=8.083
PLC chu kỳ  5: KQ=1 mã lỗi=  0 L=60.010 P=39.999 D1=8.002 D2=8.000
PLC chu kỳ  6: KQ=1 mã lỗi=  0 L=60.011 P=40.000 D1=7.998 D2=8.000
PLC chu kỳ  7: KQ=1 mã lỗi=  0 L=60.009 P=40.000 D1=7.998 D2=8.001
PLC chu kỳ  8: KQ=1 mã lỗi=  0 L=60.010 P=40.000 D1=8.003 D2=7.999
PLC chu kỳ  9: KQ=2 mã lỗi=  1 L=60.149 P=40.000 D1=8.000 D2=7.998
PLC chu kỳ 10: KQ=1 mã lỗi=  0 L=60.011 P=40.000 D1=8.000 D2=7.999
PLC chu kỳ 11: KQ=3 mã lỗi=256 L=0.000 P=0.000 D1=0.000 D2=0.000
PLC chu kỳ 12: KQ=1 mã lỗi=  0 L=60.010 P=39.999 D1=8.000 D2=7.999
CSDL: {'LOI': 1, 'NG': 2, 'OK': 9} | ảnh NG/LỖI: 3 | PLC khớp CSDL: True
Thời gian trigger→DONE (tùy máy): trung vị 168 ms, lớn nhất 274 ms

PLC nhận đủ 12 kết quả, số đo float32 giải mã đúng, mã lỗi đúng bit (8 = D2, 1 = L, 256 = không định vị được). Thời gian trigger→DONE ở đây gồm cả việc vẽ ảnh tổng hợp (camera giả khoảng 100 ms), nên dài hơn thời gian xử lý thật; số này tùy máy. Mỗi bản ghi lưu cả hệ số mm/px và ngày hiệu chuẩn: ba tháng sau khi khách hàng khiếu nại, bạn trả lời được "tấm này được đo với hiệu chuẩn nào".

7.5. Biên bản nghiệm thu tự sinh

Script cuối gom kết quả các kịch bản thành bảng và ghi ra bao_cao_nghiem_thu.md. Mỗi lần sửa thuật toán, chạy lại toàn bộ để có biên bản mới; không bao giờ sửa tay con số trong biên bản.

# ===== bao_cao.py — bảng nghiệm thu tự động (in ra màn hình + ghi file Markdown) =====
import numpy as np

hc = bo_do.hc
p50, p99 = np.percentile(t_ms, [50, 99])
hang = [
    ("R1", "Phần dư fit lưới chấm", "≤ 0.10 px", f"{hc['phan_du_max_px']:.3f} px",
     hc["phan_du_max_px"] <= 0.10),
]
for k in KT:
    r = KQ_LAP[k]
    hang.append(("R2", f"%GRR (lặp lại) {k}", "≤ 10 %", f"{r['grr']:.1f} %", r["grr"] <= 10))
    T = 2 * CFG["kich_thuoc"][k][1]
    hang.append(("R3", f"|bias| lớn nhất {k}", f"≤ {0.1 * T * 1000:.0f} µm", f"{r['bias_max_um']:.1f} µm",
                 r["bias_max_um"] <= 0.1 * T * 1000))
hang += [
    ("R4", "Escape trên mẫu vàng NG", "0", f"{KQ_VANG['escape']}/{KQ_VANG['so_ng']}", KQ_VANG["escape"] == 0),
    ("R4", "Sai mã lỗi trên mẫu NG", "0", f"{KQ_VANG['sai_ma']}/{KQ_VANG['so_ng']}", KQ_VANG["sai_ma"] == 0),
    ("R5", "Overkill trên mẫu vàng OK", "0", f"{KQ_VANG['overkill']}/{KQ_VANG['so_ok']}",
     KQ_VANG["overkill"] == 0),
    ("R6", "Băng tải trống → LỖI", "LỖI", "LỖI" if KQ_VANG["trong_ok"] else "sai", KQ_VANG["trong_ok"]),
    ("R7", "Thời gian xử lý p99 (*)", "≤ 150 ms", f"{p99:.0f} ms", p99 <= 150),
    ("R8", "PLC nhận đủ, khớp CSDL", "12/12", f"{KQ_PLC['so_chu_ky']}/12" + ("" if KQ_PLC["khop"] else " lệch"),
     KQ_PLC["so_chu_ky"] == 12 and KQ_PLC["khop"]),
    ("R9", "Ảnh NG/LỖI có trên đĩa", "3/3", f"{KQ_PLC['so_anh']}/3", KQ_PLC["anh_du"] and KQ_PLC["so_anh"] == 3),
]

dong = ["| Mã | Hạng mục | Yêu cầu | Kết quả | Đánh giá |", "|---|---|---|---|---|"]
for ma, muc, yc, kq, dat in hang:
    dong.append(f"| {ma} | {muc} | {yc} | {kq} | {'ĐẠT' if dat else 'KHÔNG ĐẠT'} |")
tong = all(h[-1] for h in hang)
noi_dung = (f"# Biên bản thử nghiệm trạm {CFG['tram']['ten']}\n\n"
            f"Hiệu chuẩn ngày {hc['ngay']}: {hc['mm_moi_px']:.6f} mm/px, bù biên {hc['bu_mm']}\n\n"
            + "\n".join(dong) + f"\n\n(*) xử lý ảnh trên máy thử, không gồm chụp ảnh. Trung vị {p50:.0f} ms.\n"
            + f"\nKết luận: {'ĐẠT' if tong else 'KHÔNG ĐẠT'} ({sum(h[-1] for h in hang)}/{len(hang)} hạng mục)\n")
with open("bao_cao_nghiem_thu.md", "w", encoding="utf-8") as f:
    f.write(noi_dung)

for ma, muc, yc, kq, dat in hang:
    print(f"{ma} {muc:28s} {yc:10s} {kq:12s} {'ĐẠT' if dat else 'KHÔNG ĐẠT'}")
print(f"Kết luận: {'ĐẠT' if tong else 'KHÔNG ĐẠT'} ({sum(h[-1] for h in hang)}/{len(hang)}) → bao_cao_nghiem_thu.md")
R1 Phần dư fit lưới chấm        ≤ 0.10 px  0.017 px     ĐẠT
R2 %GRR (lặp lại) L             ≤ 10 %     4.3 %        ĐẠT
R3 |bias| lớn nhất L            ≤ 20 µm    0.3 µm       ĐẠT
R2 %GRR (lặp lại) P             ≤ 10 %     5.2 %        ĐẠT
R3 |bias| lớn nhất P            ≤ 10 µm    0.4 µm       ĐẠT
R2 %GRR (lặp lại) D1            ≤ 10 %     5.3 %        ĐẠT
R3 |bias| lớn nhất D1           ≤ 10 µm    0.3 µm       ĐẠT
R2 %GRR (lặp lại) D2            ≤ 10 %     6.1 %        ĐẠT
R3 |bias| lớn nhất D2           ≤ 10 µm    0.1 µm       ĐẠT
R4 Escape trên mẫu vàng NG      0          0/30         ĐẠT
R4 Sai mã lỗi trên mẫu NG       0          0/30         ĐẠT
R5 Overkill trên mẫu vàng OK    0          0/30         ĐẠT
R6 Băng tải trống → LỖI         LỖI        LỖI          ĐẠT
R7 Thời gian xử lý p99 (*)      ≤ 150 ms   39 ms        ĐẠT
R8 PLC nhận đủ, khớp CSDL       12/12      12/12        ĐẠT
R9 Ảnh NG/LỖI có trên đĩa       3/3        3/3          ĐẠT
Kết luận: ĐẠT (16/16) → bao_cao_nghiem_thu.md

Dòng R7 phụ thuộc máy (đo trên máy thử 4 nhân; ảnh 5 MP thật sẽ chậm hơn nhiều lần, hãy đo lại trên PC công nghiệp của trạm).

7.6. Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
L và D lệch cùng một lượng nhưng ngược chiều so với CMMSai lệch biên (ánh sáng loe, ngưỡng cạnh)Bù theo loại kích thước bằng master; không bù kích thước tâm-tới-tâm
Độ lặp lại tốt nhưng chi tiết gần giới hạn bị phán xét saiSai số phụ thuộc kích thước (tuyến tính kém)Thử tuyến tính; lấy mẫu dày hơn, nội suy Gauss
Thỉnh thoảng kích thước nhảy vài trăm µmCaliper bắt nhầm cạnh khác (ba via, bụi, cạnh lỗ)Khai báo đúng cực tính, thu hẹp cửa sổ, ngưỡng gradient, fit bền vững
Kết quả đổi khi tấm xoay gần 90°Tin góc minAreaRect trực tiếpTính góc cạnh dài từ boxPoints, chuẩn hóa về [−90°, 90°)
Băng tải trống mà báo OKKhông kiểm tra định vị thất bạiMọi đường "không đo được" phải ra LỖI, không bao giờ ra OK
PLC đọc số thực vô nghĩaGhi hai thanh ghi bằng hai lệnh, hoặc sai thứ tự wordMột lệnh write_registers, thống nhất ABCD trong đặc tả

8. Sản phẩm bàn giao cho portfolio

Checklist README (cấu trúc chung xem bài "Xây portfolio dự án AI/Vision để xin việc"):

  • Một câu bài toán + một ảnh: "Đo 4 kích thước tấm đế dập, dung sai ±0,05 mm, 100% sản phẩm, 2 s/sp". Ảnh kết quả có vẽ đường quét, điểm cạnh, đường fit và giá trị mm.
  • Bảng kết quả nghiệm thu (chép từ biên bản tự sinh): %GRR, bias, escape, overkill, thời gian xử lý.
  • Sơ đồ kiến trúc và bảng địa chỉ Modbus.
  • Cách chạy: một lệnh tạo dữ liệu, hiệu chuẩn, chạy toàn bộ kịch bản thử và sinh biên bản.
  • Quyết định kỹ thuật và lý do: vì sao đèn nền, vì sao bù biên theo loại kích thước, vì sao nội suy Gauss (kèm con số 12 µm → 1,5 µm của mục 7.2).
  • Giới hạn: dữ liệu tổng hợp, chưa có méo ống kính, chưa thử trên chi tiết thật; trạm thật cần làm gì thêm.

Video demo 2–3 phút: (1) 15 giây bối cảnh; (2) chạy hiệu chuẩn, nói con số phần dư; (3) chạy trạm với PLC giả, cho thấy PLC nhận NG đúng mã lỗi và ảnh NG xuất hiện trong thư mục; (4) mở biên bản, chỉ vào %GRR và kịch bản tuyến tính. Quay màn hình, chữ đủ lớn, không nhạc nền ồn.

Khi phỏng vấn, kể câu chuyện pixel-lockingNhà tuyển dụng vị trí Vision Engineer gặp rất nhiều ứng viên "chạy được OpenCV". Ít người kể được: "độ lặp lại của tôi 1 µm nhưng tôi phát hiện sai số hệ thống 12 µm theo kích thước, tìm ra nguyên nhân và sửa". Đó là tư duy đo lường mà nhà máy cần.

9. Tự đánh giá

Tiêu chíChưa đạtĐạtTốt
Hiệu chuẩnHệ số mm/px gõ tayTừ lưới chấm, có phần dư, lưu file có ngàyThêm bù biên bằng master, kiểm tra tỉ lệ hai trục, hướng dẫn hiệu chuẩn lại
Thuật toán đoĐo bằng contour, sai ±1 pxCaliper subpixel, fit đường/tròn, dời theo vị trí vậtFit bền vững, thử tuyến tính, giải thích được mọi nguồn sai số
Đánh giá"Chạy thử thấy đúng"Lặp lại 3 × 30, %GRR, bias, mẫu vàngThêm tuyến tính, băng tải trống, biên bản tự sinh
Tích hợpChỉ in kết quả ra màn hìnhModbus đúng bắt tay, ghi kết quả trước DONE, SQLiteTự kết nối lại, ghi nền, log xoay vòng, timeout mọi chỗ chờ
An toàn quyết địnhLỗi định vị bị bỏ quaKhông đo được → LỖIMã lỗi dạng bit, ảnh NG/LỖI lưu kèm hệ số hiệu chuẩn
Tài liệuKhông READMEREADME theo checklist mục 8Đặc tả, hướng dẫn hiệu chuẩn, biên bản, video demo

10. Mở rộng

  • Méo ống kính: thêm méo xuyên tâm vào bộ sinh dữ liệu (dịch mỗi pixel theo k₁·r²), quan sát phần dư lưới tăng, rồi thay quy đổi đơn giản bằng hiệu chuẩn đầy đủ của chặng Calibration.
  • Ba via mép lỗ: vẽ thêm gờ nhỏ vào mép lỗ, so fit_tron_kasa với fit_tron_ben.
  • Nhận biết chiều: thêm một vát góc không đối xứng để phân biệt lỗ trái/phải khi tấm bị lật 180°.
  • Nghiên cứu hệ đo loại 1: đo master 50 lần không nạp lại, tính Cg, Cgk như bài nghiệm thu FAT/SAT.
  • Theo dõi xu hướng: vẽ biểu đồ P theo thời gian từ SQLite; cảnh báo khi trung bình trượt tiến gần giới hạn, trước khi ra hàng NG. Đó chính là vấn đề chày đột mòn trong bối cảnh.

Bài 1. Bản vẽ đổi dung sai đường kính lỗ thành ±0,02 mm. Với σ lặp lại của D2 trong mục 7.1, %GRR mới là bao nhiêu? Trạm còn đạt R2 không?

Xem đáp án

T = 0,04 mm. 6σ = 6,1 µm (theo bảng 7.1) → %GRR = 6,1 / 40 ≈ 15%: không đạt 10%, chỉ ở vùng "có điều kiện" 10–30%. Cần tăng độ phân giải (trạm thật 0,041 mm/px tốt hơn bản mô phỏng khoảng 2 lần), tăng số tia hoặc độ rộng dải lấy trung bình, và chạy lại thử nghiệm.

Bài 2. Vì sao bù biên cho L và D nhưng không bù cho P? Nếu đèn nền loe không đều (bên trái 0,2 px, bên phải 0,1 px), P có bị ảnh hưởng không?

Xem đáp án

Loe đều làm mỗi cạnh dịch đều nhau về phía vật liệu: kích thước cạnh-tới-cạnh thay đổi, còn tâm lỗ (trung bình các cạnh đối xứng) không đổi, nên P không cần bù. Nếu loe không đều theo vị trí trong ảnh, mỗi lỗ vẫn loe gần như đều quanh chính nó nên tâm vẫn ổn; nhưng L sẽ phụ thuộc vị trí tấm trong ảnh, và một giá trị bù duy nhất không còn đủ. Khi đó cần làm đều đèn hoặc bù theo vị trí.

Bài 3. PLC đặt ngân sách 300 ms. Trạm thật: phơi sáng 2 ms, truyền ảnh 5 MP qua GigE khoảng 50 ms, xử lý p99 120 ms, Modbus 2 lệnh × 5 ms. Còn bao nhiêu dự trữ? Nên giữ dự trữ để làm gì?

Xem đáp án

2 + 50 + 120 + 10 = 182 ms, dự trữ 118 ms (khoảng 40%). Dự trữ dành cho: hệ điều hành bị chậm đột ngột (ghi đĩa, cập nhật), số tia tăng khi cần độ lặp lại tốt hơn, và trường hợp phải chụp lại. Nguyên tắc chung của chặng Industrial Vision: không thiết kế trạm chạy sát ngân sách.

Tóm tắt

  • Trạm đo = hiệu chuẩn tỉ lệ (lưới chấm) + bù biên (master) + định vị + caliper subpixel + fit + phán xét; phần đo tách khỏi PLC và lưu trữ.
  • Bù biên theo loại kích thước: cạnh-tới-cạnh cần bù, tâm-tới-tâm thì không.
  • Chứng minh bằng số: %GRR = 6σ/T, bias so với CMM, tuyến tính trên cả dải dung sai, escape/overkill trên mẫu vàng.
  • Độ lặp lại tốt không có nghĩa là đo đúng: sai số hệ thống kiểu pixel-locking chỉ lộ ra khi thử tuyến tính.
  • Không đo được thì báo LỖI; ghi kết quả trước DONE bằng một lệnh; lưu hệ số hiệu chuẩn cùng mỗi bản ghi.
  • Biên bản nghiệm thu tự sinh từ script, chạy lại được, là sản phẩm portfolio thuyết phục nhất.

Dự án tiếp theo: Capstone 2: trạm đọc DataMatrix và số seri, nơi độ chính xác được đo bằng tỉ lệ đọc đúng và tốc độ dây chuyền thay cho micromet.

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