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.
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
Đâ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.
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ệu | Kích thước | Danh định | Dung sai | Dải dung sai T | Nguyên nhân lỗi thường gặp |
|---|---|---|---|---|---|
| L | Chiều dài tấm | 60,00 mm | ±0,10 mm | 0,20 mm | Bước cấp phôi sai, dao cắt mòn |
| P | Khoảng cách tâm hai lỗ | 40,00 mm | ±0,05 mm | 0,10 mm | Chày đột lỏng, dịch chuyển |
| D1, D2 | Đường kính lỗ trái, phải | 8,00 mm | ±0,05 mm | 0,10 mm | Chà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.
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ầu | Tiêu chí đạt | Cách thử |
|---|---|---|---|
| R1 | Hiệu chuẩn tỉ lệ mm/px bằng tấm lưới chấm bước 4,000 mm | Phần dư lớn nhất của phép fit ≤ 0,10 px; kết quả lưu file có ngày | Chụ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 |
| R4 | Không lọt lỗi, báo đúng kích thước lỗi | Escape = 0 và sai mã lỗi = 0 trên mẫu vàng NG | 5 mẫu NG × 6 lần, đổi vị trí mỗi lần |
| R5 | Không loại nhầm | Overkill = 0 trên mẫu vàng OK | 5 mẫu OK × 6 lần |
| R6 | Không có sản phẩm hoặc không định vị được thì không bao giờ báo OK | Băng tải trống → kết quả LỖI | Ảnh nền trống |
| R7 | Thờ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 |
| R8 | Giao tiếp PLC Modbus TCP, bắt tay TRIGGER/DONE | 12/12 chu kỳ, kết quả và số đo PLC nhận khớp CSDL | PLC giả chạy 12 sản phẩm, có NG và LỖI |
| R9 | Lưu kết quả và ảnh NG có truy vết | Mọ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ùng | Truy vấn CSDL sau khi chạy R8 |
| R10 | Tài liệu | README, đặc tả 1 trang, hướng dẫn hiệu chuẩn lại, biên bản thử tự sinh | Xem 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,
minAreaRectvà cách tính góc cạnh dài từboxPointsthay 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 ghi | Nội dung |
|---|---|---|
| coil 0 | PLC | TRIGGER |
| coil 1, 2, 3 | Vision | READY, BUSY, DONE |
| HR 0, 1 | PLC | Mã sản phẩm, số chu kỳ |
| HR 10 | Vision | Kết quả: 1 OK, 2 NG, 3 LỖI |
| HR 11 | Vision | Số chu kỳ của kết quả (trả lại HR 1) |
| HR 12 | Vision | Mã lỗi: bit 0 L, bit 1 P, bit 2 D1, bit 3 D2, bit 8 không định vị được |
| HR 14–21 | Vision | L, 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ốc | Việc chính | Sản phẩm | Xong khi | Thời gian |
|---|---|---|---|---|
| M1 | Đọc bối cảnh, viết đặc tả 1 trang; chạy bộ sinh dữ liệu | docs/dac_ta.md, ảnh mẫu | Mọi yêu cầu R1–R10 có con số; bảng địa chỉ PLC đã chốt | 45 phút |
| M2 | Hiệu chuẩn tỉ lệ bằng lưới chấm | hieu_chuan.py, hieu_chuan.json | Sai số tỉ lệ so với đáp án ẩn < 50 ppm; phần dư ≤ 0,10 px | 60 phút |
| M3 | Định vị, caliper subpixel, fit; bù biên bằng master | do_luong.py, bo_do.py | Trên ảnh có đáp án, mọi kích thước sai < 5 µm sau bù; đúng mã lỗi | 90 phút |
| M4 | Đánh giá: lặp lại, tuyến tính, mẫu vàng | danh_gia/*.py, lap_lai.csv | R2–R7 đạt; giải thích được từng con số | 60 phút |
| M5 | Tích hợp PLC giả, SQLite, ảnh NG | plc.py, luu_tru.py | R8–R9 đạt; chạy lại 3 lần liên tiếp không lỗi cổng, không treo | 60 phút |
| M6 | Biên bản tự sinh, README, video demo | bao_cao_nghiem_thu.md, README, video 2–3 phút | Người lạ clone repo, chạy một lệnh, ra biên bản giống bạn | 45 phú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:
- 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.
- Góc của vật: góc của
minAreaRectcó 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. - Độ 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ản | Mục đích | Yêu cầu liên quan |
|---|---|---|
| 7.1 Độ lặp lại 3 × 30 | σ lặp lại, %GRR, bias so với CMM | R2, R3, R7 |
| 7.2 Độ tuyến tính | Sai số có thay đổi theo kích thước không | R3 (trên cả dải dung sai) |
| 7.3 Bộ mẫu vàng | Escape, overkill, đúng mã lỗi, băng tải trống | R4, R5, R6 |
| 7.4 Tích hợp PLC + lưu trữ | Bắt tay, dữ liệu PLC khớp CSDL, ảnh NG | R8, R9 |
| 7.5 Biên bản | Gom mọi kết quả thành bảng ĐẠT/KHÔNG ĐẠT | R1–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.
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ứng | Nguyên nhân | Cách sửa |
|---|---|---|
| L và D lệch cùng một lượng nhưng ngược chiều so với CMM | Sai 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 sai | Sai 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 µm | Caliper 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ếp | Tí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 OK | Không kiểm tra định vị thất bại | Mọi đường "không đo được" phải ra LỖI, không bao giờ ra OK |
| PLC đọc số thực vô nghĩa | Ghi hai thanh ghi bằng hai lệnh, hoặc sai thứ tự word | Mộ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.
9. Tự đánh giá
| Tiêu chí | Chưa đạt | Đạt | Tốt |
|---|---|---|---|
| Hiệu chuẩn | Hệ số mm/px gõ tay | Từ lưới chấm, có phần dư, lưu file có ngày | Thê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 px | Caliper subpixel, fit đường/tròn, dời theo vị trí vật | Fit 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àng | Thêm tuyến tính, băng tải trống, biên bản tự sinh |
| Tích hợp | Chỉ in kết quả ra màn hình | Modbus đúng bắt tay, ghi kết quả trước DONE, SQLite | Tự kết nối lại, ghi nền, log xoay vòng, timeout mọi chỗ chờ |
| An toàn quyết định | Lỗi định vị bị bỏ qua | Không đo được → LỖI | Mã lỗi dạng bit, ảnh NG/LỖI lưu kèm hệ số hiệu chuẩn |
| Tài liệu | Không README | README 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_kasavớifit_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.


