Capstone: trạm đọc DataMatrix, OCR/OCV số seri và chống trùng theo lệnh sản xuất
Dự án portfolio: đọc mã 2D và số seri trong một chu kỳ, OCV chất lượng in, xác minh lệnh sản xuất, Luhn, chống trùng SQLite, đo p99 theo tốc độ dây chuyền.
Học xong bài này, bạn sẽ
- Đọc mã 2D và OCR trong một chu kỳ
- Xác minh với dữ liệu lệnh sản xuất
- Đạt tốc độ dây chuyền
Nên học trước: Capstone 1: trạm đo kích thước hoàn chỉnh
Nội dung bài viết
Capstone 1 đo micromet; dự án này đo bằng một thước khác: tỉ lệ đọc đúng, số hộp lọt lỗi và tốc độ dây chuyền. Bạn xây trạm kiểm tra nhãn trên dây chuyền đóng hộp: trong một chu kỳ, đọc mã 2D, đọc dòng số seri in bên cạnh (OCR), xác minh chất lượng in (OCV), đối chiếu với lệnh sản xuất (định dạng, chữ số kiểm tra, dải seri, chống trùng bằng CSDL) và trả OK/NG kèm lý do trong ngân sách thời gian của dây chuyền. Dự án dùng các bài đọc mã và OCR của chặng Measurement / Matching / OCR, cách viết tiêu chí của chặng Industrial Vision, và cách lưu dữ liệu của chặng PLC / Robot / MES. Phần chạy thử dùng một số thành phần thay thế (mã QR thay cho DataMatrix, OCR theo mẫu thay cho Tesseract), được nói rõ ở mục 6.
1. Bối cảnh
Một nhà máy lắp ráp bộ nguồn 12 V 60 W đóng mỗi bộ nguồn vào một hộp giấy. Máy in nhãn nhiệt dán lên mỗi hộp một nhãn gồm: mã 2D chứa mã sản phẩm (PN), số seri (SN) và số lô (LOT); dòng chữ số seri in to bên cạnh cho người đọc; dòng PN và LOT cỡ nhỏ. Khách hàng (một hãng phân phối) yêu cầu: không bao giờ có hai hộp cùng số seri, chữ in phải khớp mã, và mọi số seri phải truy được về lệnh sản xuất. Quý trước có hai sự cố: máy in bị lỗi phần mềm in lại một dải seri cũ sau khi khởi động lại, và đầu in bị tắc một dải làm chữ mất nét mà không ai để ý suốt nửa ca.
Thông số dây chuyền:
| Thông số | Giá trị | Hệ quả cho trạm vision |
|---|---|---|
| Tốc độ băng tải | 0,5 m/s, hộp dài 160 mm, khe hở 40 mm | Bước 200 mm → 150 hộp/phút, 400 ms/hộp |
| Trigger | Cảm biến quang báo hộp đến, camera chụp ngay (global shutter) | Phơi sáng ≤ 100 µs: nhòe do chuyển động ≤ 0,05 mm |
| Vị trí nhãn | Lệch ±1 mm, xoay ±4° do máy dán nhãn | Phải định vị lại dòng chữ theo từng ảnh |
| Cổng gạt NG | Đặt sau camera 600 mm (1,2 s) | PLC theo dõi hộp bằng hàng đợi; trạm phải trả kết quả trước hộp kế tiếp |
| Số seri | VN + năm tháng (YYMM) + mã line + 5 chữ số thứ tự + 1 chữ số kiểm tra Luhn, ví dụ VN2610A001238 | 13 ký tự, vị trí chữ cái và chữ số cố định |
| Lệnh sản xuất | MES gửi khi đổi lệnh: PO, PN, LOT, dải seri (ví dụ 00100–00599) | Mọi hộp phải khớp lệnh đang chạy |
Ngân sách thời gian: hộp cách nhau 400 ms, nên trạm xử lý tuần tự phải xong mỗi hộp trong chưa tới 400 ms kể cả truyền ảnh và trả kết quả. Ta đặt yêu cầu thời gian xử lý p99 ≤ 150 ms, để dành chỗ cho truyền ảnh, giao tiếp và những lúc hệ điều hành chậm đột ngột (cách tính đã học ở bài "Trigger, encoder và cycle time"). Dùng p99 chứ không dùng trung bình: một hộp bị xử lý chậm là đủ làm lệch hàng đợi của PLC.
2. Yêu cầu và tiêu chí nghiệm thu
| Mã | Yêu cầu | Tiêu chí đạt | Cách thử |
|---|---|---|---|
| R1 | Đọc mã 2D trên nhãn tốt, kể cả nhãn mờ, tương phản thấp | 100% trên bộ nhãn vàng (sản xuất: theo dõi tỉ lệ đọc từng ca) | Bộ 300 nhãn, trong đó 256 nhãn tốt |
| R2 | OCR dòng seri | Đọc đúng hoàn toàn 100% nhãn tốt | So với đáp án |
| R3 | Không lọt nhãn lỗi | Escape = 0 trên 44 nhãn lỗi thuộc 8 loại | Mã hỏng, giọt mực, mất nét, chữ khác mã, trùng, sai lệnh, sai chữ số kiểm tra, ngoài dải |
| R4 | Không loại nhầm | Overkill ≤ 0,5% nhãn tốt | Có 18 nhãn mờ trong nhóm nhãn tốt |
| R5 | Báo đúng lý do NG | 100% NG đúng lý do | So lý do với loại lỗi đã tiêm |
| R6 | Truy vết | Mọi hộp có bản ghi: thời gian, PO, seri trong mã, seri đọc được, kết quả, lý do, điểm OCV, thời gian xử lý | Đếm bản ghi CSDL |
| R7 | Tốc độ | Thời gian xử lý p99 ≤ 150 ms trên PC của trạm | Đo mọi chu kỳ của bộ 300 nhãn |
| R8 | Chống trùng bền vững | Trùng seri bị chặn kể cả sau khi khởi động lại chương trình | Ràng buộc khóa chính trong SQLite, không dùng biến trong RAM |
3. Kiến thức sử dụng
- Dự án "Đọc mã QR và DataMatrix trên bao bì": đọc mã,
pylibdmtx, gốc tọa độ dưới-trái của libdmtx. - Bài "OCR với Tesseract: tiền xử lý ảnh để đọc chữ chính xác", bài "OCR bằng Deep Learning: PaddleOCR và EasyOCR", bài "OCR và OCV trong công nghiệp" và dự án "Đọc và xác minh số seri in trên linh kiện": whitelist, sửa nhầm lẫn theo định dạng, OCV.
- Bài "Template matching trong OpenCV":
matchTemplate,TM_CCOEFF_NORMED, dời công cụ theo vị trí vật. - Chặng Industrial Vision: escape/overkill, ngân sách cycle time, đặc tả và nghiệm thu.
- Chặng PLC / Robot / MES: bài lưu kết quả vào SQLite (khóa chính, WAL, tham số), bài Modbus TCP (trả kết quả cho PLC, cách làm như Capstone 1).
pip install opencv-python numpy
# Trên máy bạn, cho trạm thật: pip install pylibdmtx pytesseract (+ thư viện libdmtx và chương trình Tesseract)
# Bài viết chạy với Python 3.11+, OpenCV 5.0, NumPy 2.5
4. Kiến trúc tham chiếu
MES ──(đổi lệnh: PO, PN, LOT, dải seri)──► LENH_SX
│
Cảm biến ─► Camera ─► ảnh ─► TramMa.xu_ly(anh) ▼
1. doc_ma() mã 2D → nội dung + 3 góc mốc (bậc thang thử lại)
2. _xac_minh_ma() trường PN/SN/LOT, định dạng, Luhn, dải, lệnh SX
3. nan_nhan() affine từ 3 góc mã → nhãn chuẩn 500 × 200
4. doc_seri() căn dòng → OCR theo ô + OCV (NCC, diện tích lỗi in)
5. chống trùng INSERT vào seri_da_dung (khóa chính)
6. ghi ket_qua mọi hộp, OK hay NG
│
├─► PLC: kết quả + mã lý do (Modbus, như Capstone 1)
└─► SQLite: ket_qua, seri_da_dung
Hai ý tưởng thiết kế quan trọng nhất:
- Mã 2D là mốc định vị. Không cần template của logo hay góc nhãn: vị trí và hướng của mã cho biết dòng chữ nằm đâu. Bố cục (tọa độ góc mã trên nhãn chuẩn) được học từ một nhãn mẫu, không gõ tay.
- Mã 2D là "đáp án" cho OCV. Mã có sửa lỗi Reed-Solomon nên đáng tin hơn chữ in. Trạm đọc mã trước, rồi kiểm tra chữ in có đúng là chuỗi đó và in đủ tốt không. OCR vẫn cần để phân biệt hai loại NG: chữ in rõ nhưng là seri khác (lỗi dữ liệu máy in, nghiêm trọng) và chữ in kém (lỗi đầu in).
Cấu trúc repo đề xuất:
tram-doc-ma-ocr/
├── README.md
├── cau_hinh.toml định dạng seri, ngưỡng OCV, ngân sách thời gian
├── lenh_sx/PO-2610-0815.json lệnh sản xuất nhận từ MES (mẫu ở dưới)
├── mau/ nhãn mẫu để học bố cục và mẫu ký tự
├── tram_ma/
│ ├── doc_ma.py bậc thang đọc mã; bản DataMatrix và bản QR cùng giao diện
│ ├── bo_cuc.py học góc mã, mẫu ký tự
│ ├── doc_seri.py nắn nhãn, căn dòng, OCR + OCV
│ ├── xac_minh.py TramMa: lệnh SX, Luhn, dải, trùng, thứ tự
│ └── luu_tru.py SQLite
├── danh_gia/ sinh_nhan.py, danh_gia.py, bao_cao.py
└── tests/ test_luhn.py, test_xac_minh.py
Lệnh sản xuất là dữ liệu, không phải code. Mẫu file MES gửi khi đổi lệnh:
{"po": "PO-2610-0815", "pn": "PSU-12V-60W", "lot": "L2610A",
"yymm": "2610", "line": "A", "stt_dau": 100, "stt_cuoi": 599}
Bảng mã lý do gửi PLC (HR 12, cùng bảng địa chỉ với Capstone 1; HR 10 vẫn là 1 OK / 2 NG): 0 không lỗi, 1 không đọc được mã, 2 nội dung mã sai, 3 sai định dạng, 4 sai lệnh SX, 5 sai chữ số kiểm tra, 6 ngoài dải seri, 7 chữ khác mã, 8 chữ in kém, 9 trùng seri. PLC thường chỉ cần OK/NG để gạt; mã lý do để hiển thị HMI và thống kê theo ca.
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 tả: định dạng seri, quy tắc xác minh, bảng lý do NG, ngân sách thời gian | docs/dac_ta.md | Mỗi lý do NG có định nghĩa kiểm chứng được | 45 phút |
| M2 | Bộ sinh nhãn + đọc mã có bậc thang thử lại | sinh_nhan.py, doc_ma.py | Đọc được 200/200 nhãn tốt ngẫu nhiên | 60 phút |
| M3 | Học bố cục, nắn nhãn, OCR + OCV | bo_cuc.py, doc_seri.py | OCR đúng 100% nhãn tốt; điểm OCV tách được nhãn tốt và nhãn in kém | 90 phút |
| M4 | Xác minh với lệnh SX, Luhn, dải, trùng (SQLite), thứ tự | xac_minh.py | Mỗi loại lỗi có ít nhất 1 ca thử ra đúng lý do; trùng bị chặn sau khi khởi động lại | 60 phút |
| M5 | Đánh giá bộ 300 nhãn, đo thời gian, chỉnh ngưỡng | danh_gia.py, biên bản | R1–R8 đạt; ghi lại cách chọn mọi ngưỡng | 60 phút |
| M6 | Chuyển sang DataMatrix + Tesseract/PaddleOCR trên máy bạn; README, video | Repo hoàn chỉnh | Cùng bộ đánh giá chạy được với cả hai bản | 45 phút |
6. Mã khởi đầu và phần lõi
6.1. Bản demo dùng gì thay cho gì
Môi trường chạy thử của bài không cài được thư viện hệ thống libdmtx (cần cho pylibdmtx) và chương trình Tesseract. Để mọi khối code vẫn chạy được và kiểm chứng được, bản demo dùng hai thành phần thay thế. Toàn bộ phần còn lại (nắn nhãn, OCV, xác minh, chống trùng, đánh giá) giống hệt trạm thật.
| Thành phần | Trạm thật (bạn dùng trên máy mình) | Bản demo trong bài |
|---|---|---|
| Mã 2D | DataMatrix ECC 200, đọc bằng pylibdmtx (libdmtx) hoặc thư viện/đầu đọc thương mại | QR Code sinh bằng cv2.QRCodeEncoder, đọc bằng cv2.QRCodeDetector và cv2.QRCodeDetectorAruco |
| OCR | Tesseract (--psm 7, whitelist) hoặc PaddleOCR, cộng bước sửa nhầm lẫn theo định dạng | "OCR theo mẫu": so NCC từng ô chữ với 36 mẫu ký tự vẽ bằng font Hershey của OpenCV, đúng font mà "máy in" giả dùng |
| OCV | Giống nhau: so từng ký tự với ký tự mong đợi (từ mã 2D) bằng NCC và diện tích pixel lệch | |
| Chất lượng in của mã | Máy kiểm định mã (verifier) chấm điểm theo ISO/IEC 15415 nếu khách hàng yêu cầu | Không có |
6.2. Mã khởi đầu: nhãn có đáp án và lỗi biết trước
Bộ sinh nhãn vẽ nhãn thẳng 500 × 200 px (module mã 5 px, chữ seri bước cố định 22 px như máy in nhãn công nghiệp), rồi "dán" lên hộp trong ảnh 640 × 480 với độ lệch, góc xoay, độ mờ, tương phản, nhiễu ngẫu nhiên. Tham số loi tiêm lỗi in: mã bị xước, giọt mực, đầu in tắc, nhãn mờ. Hàm chu_so_luhn tính chữ số kiểm tra, thử bằng ví dụ chuẩn của thuật toán Luhn.
# ===== sinh_nhan.py — "camera giả": ảnh nhãn hộp có đáp án và lỗi biết trước =====
import math
import cv2
import numpy as np
W_ANH, H_ANH = 640, 480
W_NHAN, H_NHAN = 500, 200
FONT, CO_CHU, NET = cv2.FONT_HERSHEY_SIMPLEX, 0.9, 2 # máy in nhãn: font cố định, bước chữ cố định
BUOC_CHU, X_SERI, Y_SERI = 22, 200, 112 # ô chữ rộng 22 px, chân dòng seri tại y = 112
QR = cv2.QRCodeEncoder.create() # ĐỨNG THAY DataMatrix trong bản demo (xem mục 6)
def chu_so_luhn(chu_so):
"""Chữ số kiểm tra Luhn cho một chuỗi chữ số."""
tong = 0
for i, c in enumerate(reversed(chu_so)):
d = int(c) * (2 if i % 2 == 0 else 1) # nhân đôi từ chữ số sát chữ số kiểm tra
tong += d - 9 if d > 9 else d
return str((10 - tong % 10) % 10)
def tao_seri(yymm, line, stt):
"""VN + YYMM + mã line + 5 chữ số thứ tự + 1 chữ số kiểm tra Luhn → 13 ký tự."""
than = f"{yymm}{stt:05d}"
return f"VN{yymm}{line}{stt:05d}{chu_so_luhn(than)}"
def ve_nhan(pn, seri, lot, seri_in=None):
"""Nhãn thẳng (hệ tọa độ nhãn). seri_in: chữ in khác mã (mô phỏng lỗi dữ liệu máy in)."""
nhan = np.full((H_NHAN, W_NHAN), 235, np.uint8)
ma = QR.encode(f"PN:{pn};SN:{seri};LOT:{lot}") # có sẵn vùng trắng 2 module quanh mã
ma = cv2.resize(ma, None, fx=5, fy=5, interpolation=cv2.INTER_NEAREST) # module 5 px
nhan[18:18 + ma.shape[0], 20:20 + ma.shape[1]] = np.where(ma < 128, 25, 235)
cv2.putText(nhan, f"PN {pn}", (X_SERI, 50), FONT, 0.6, 25, 1, cv2.LINE_AA)
for i, c in enumerate(seri_in or seri): # bước chữ cố định: ô thứ i bắt đầu ở X_SERI + i·22
(w, _), _ = cv2.getTextSize(c, FONT, CO_CHU, NET)
cv2.putText(nhan, c, (X_SERI + i * BUOC_CHU + (BUOC_CHU - w) // 2, Y_SERI), FONT, CO_CHU, 25, NET,
cv2.LINE_AA)
cv2.putText(nhan, f"LOT {lot}", (X_SERI, 150), FONT, 0.6, 25, 1, cv2.LINE_AA)
return nhan
def chup(nhan, rng, loi=None):
"""Đặt nhãn lên hộp trong ảnh camera: lệch, xoay, mờ, nhiễu; tiêm lỗi in nếu có."""
nhan = nhan.copy()
if loi == "ma_hong": # vết xước/bẩn che góc mã
cv2.circle(nhan, (60, 58), 30, 235, -1)
elif loi == "cham_muc": # giọt mực trên một ký tự của số seri
x = X_SERI + int(rng.integers(0, 13)) * BUOC_CHU + 11
cv2.circle(nhan, (x, Y_SERI - 10), 7, 25, -1)
elif loi == "thieu_net": # đầu in tắc: mất một dải ngang qua dòng seri
nhan[Y_SERI - 12:Y_SERI - 7, X_SERI:X_SERI + 13 * BUOC_CHU] = 235
goc = rng.uniform(-4, 4)
tam = (W_ANH / 2 + rng.uniform(-15, 15), H_ANH / 2 + rng.uniform(-15, 15))
M = cv2.getRotationMatrix2D((W_NHAN / 2, H_NHAN / 2), goc, 1.0)
M[:, 2] += np.array(tam) - (W_NHAN / 2, H_NHAN / 2)
anh = cv2.warpAffine(nhan, M, (W_ANH, H_ANH), flags=cv2.INTER_LINEAR, borderValue=90) # nền hộp
mo, tuong_phan = (1.3, 0.55) if loi == "mo" else (rng.uniform(0.5, 0.9), rng.uniform(0.85, 1.0))
anh = cv2.GaussianBlur(anh.astype(np.float32), (0, 0), mo)
anh = 128 + (anh - 128) * tuong_phan + rng.normal(0, 4, anh.shape)
return np.clip(anh, 0, 255).astype(np.uint8)
if __name__ == "__main__":
rng = np.random.default_rng(0)
s = tao_seri("2610", "A", 123)
cv2.imwrite("nhan_mau.png", chup(ve_nhan("PSU-12V-60W", s, "L2610A"), rng))
cv2.imwrite("nhan_cham_muc.png", chup(ve_nhan("PSU-12V-60W", s, "L2610A"), rng, loi="cham_muc"))
print("Seri mẫu:", s, "| Luhn của 7992739871 =", chu_so_luhn("7992739871"), "(đáp án chuẩn: 3)")
print("Đã lưu nhan_mau.png, nhan_cham_muc.png")
Seri mẫu: VN2610A001238 | Luhn của 7992739871 = 3 (đáp án chuẩn: 3)
Đã lưu nhan_mau.png, nhan_cham_muc.png
Một chi tiết đáng chú ý khi làm bản demo: với bộ đọc QR của OpenCV, module 4 px cho tỉ lệ đọc kém hẳn so với 5 px khi ảnh hơi mờ và xoay. Trên trạm thật cũng vậy: kích thước module trên ảnh (px/module) là thông số phải chốt khi chọn camera và ống kính, không phải để thuật toán "cố gắng".
6.3. Khung chương trình bạn cần viết
doc_ma(anh) -> (noi_dung, goc_4x2, bac) | (None, None, None)
thử lần lượt nhiều cách đọc (bậc thang), dừng ở lần đầu thành công
hoc_bo_cuc(nhan_mau) -> goc_ma_chuan chạy MỘT lần khi cài đặt / đổi mẫu nhãn
nan_nhan(anh, goc_ma) -> nhan_chuan 500 × 200 affine từ 3 góc tin cậy của mã
doc_seri(nhan, mong_doi) -> (chu_ocr, ncc[13], dien_tich_loi[13], vi_tri_kem_tin_cay)
TramMa(lenh_sx, csdl).xu_ly(anh) -> {"ket_qua", "ly_do", "seri", "chu", "ncc_min", "loi_max", "ms"}
thứ tự kiểm: đọc mã → nội dung → lệnh SX → Luhn → dải → OCR/OCV → chống trùng (cuối cùng)
Vì sao chống trùng đặt cuối cùng? Vì INSERT vào bảng seri_da_dung nghĩa là "seri này đã được dùng cho một hộp đạt". Nếu chèn trước rồi hộp bị NG vì chữ in kém, hộp làm lại với nhãn mới cùng seri sẽ bị báo trùng oan.
6.4. Lời giải phần lõi
Xem lời giải phần lõi (3 phần, chạy nối tiếp sau khối 6.2)
Phần 1: cấu hình, mẫu ký tự, học bố cục. Mẫu ký tự vẽ đúng font, cỡ, độ dày nét của máy in và làm mờ nhẹ cho giống ảnh. Bố cục học bằng cách đọc mã trên nhãn mẫu thẳng. Dòng cuối cho thấy vì sao OCR cần giới hạn tập ký tự theo định dạng: "0" và "O", "8" và "B" giống nhau tới 0,92–0,94.
# ===== bo_cuc.py — cấu hình, mẫu ký tự, học bố cục nhãn từ nhãn mẫu =====
import string
import cv2
import numpy as np
CFG = {
"dinh_dang_seri": "LLDDDDLDDDDDD", # L = chữ cái, D = chữ số (VN 2610 A 00123 7)
"o_seri": {"x": X_SERI, "y_tren": Y_SERI - 26, "cao": 34, "buoc": BUOC_CHU, "so_ky_tu": 13},
"le_tim_px": 3, # cho phép ký tự lệch ±3 px trong ô
"nguong_ocv": 0.80, # điểm khớp (NCC) tối thiểu của MỌI ký tự với ký tự mong đợi
"lech_muc_xam": 70, # pixel lệch quá 70 mức xám so với mẫu = pixel lỗi in
"dien_tich_loi_max": 25, # số pixel lỗi tối đa trong một ô ký tự
"bien_ocr": 0.05, # ký tự tốt nhất phải hơn ký tự thứ hai ít nhất 0,05
"ngan_sach_ms": 150, # p99 thời gian xử lý cho phép (mục 1)
}
def tao_mau_ky_tu():
"""Mẫu từng ký tự, vẽ đúng font/cỡ/nét của máy in, căn giữa trong ô 22 × 34 (như trên nhãn)."""
o = CFG["o_seri"]
mau = {}
for c in string.digits + string.ascii_uppercase:
tam = np.full((o["cao"], o["buoc"]), 235, np.uint8)
(w, _), _ = cv2.getTextSize(c, FONT, CO_CHU, NET)
cv2.putText(tam, c, ((o["buoc"] - w) // 2, Y_SERI - o["y_tren"]), FONT, CO_CHU, 25, NET, cv2.LINE_AA)
mau[c] = cv2.GaussianBlur(tam, (0, 0), 0.7)
return mau
def hoc_bo_cuc(nhan_mau):
"""Chạy bộ đọc mã trên NHÃN MẪU thẳng, ghi lại 4 góc mã theo tọa độ nhãn.
Lúc chạy: 4 góc tìm được trong ảnh ↔ 4 góc này → phép biến đổi phối cảnh ảnh → nhãn chuẩn."""
nen = cv2.copyMakeBorder(nhan_mau, 20, 20, 20, 20, cv2.BORDER_CONSTANT, value=235)
noi_dung, goc, _ = cv2.QRCodeDetector().detectAndDecode(nen)
if not noi_dung:
raise RuntimeError("Không đọc được mã trên nhãn mẫu")
return goc.reshape(4, 2) - 20 # bỏ phần viền vừa thêm
MAU = tao_mau_ky_tu()
GOC_MA_CHUAN = hoc_bo_cuc(ve_nhan("PSU-12V-60W", tao_seri("2610", "A", 0), "L2610A"))
print("Số mẫu ký tự:", len(MAU), "| kích thước mẫu:", MAU["0"].shape)
print("Góc mã trên nhãn chuẩn (px):", GOC_MA_CHUAN.round(1).tolist())
# Mẫu dễ nhầm: điểm khớp giữa các cặp ký tự giống nhau
for a, b in (("0", "O"), ("1", "I"), ("5", "S"), ("8", "B"), ("2", "Z")):
s = cv2.matchTemplate(MAU[a], MAU[b], cv2.TM_CCOEFF_NORMED)[0, 0]
print(f" '{a}' so với '{b}': {s:.2f}", end="")
print()
Số mẫu ký tự: 36 | kích thước mẫu: (34, 22)
Góc mã trên nhãn chuẩn (px): [[30.0, 28.0], [174.0, 28.0], [174.0, 172.0], [30.0, 172.0]]
'0' so với 'O': 0.92 '1' so với 'I': 0.79 '5' so với 'S': 0.92 '8' so với 'B': 0.94 '2' so với 'Z': 0.82
Phần 2: đọc mã, nắn nhãn, OCR + OCV. Ba chi tiết quyết định độ tin cậy, đều tìm ra khi chạy bộ đánh giá:
- Bậc thang đọc mã: chỉ dùng
QRCodeDetectorthì khoảng 10% nhãn tốt không đọc được trong thử nghiệm của bài; thử thêmQRCodeDetectorArucovà ảnh Otsu thì đọc được hết, mà nhãn đọc được ngay lần đầu không tốn thêm thời gian. - Chỉ dùng 3 góc: góc dưới-phải của QR không có mẫu định vị nên bị ước lượng, lệch vài pixel. Dùng cả 4 góc cho phép biến đổi phối cảnh làm hơn nửa số nhãn bị đọc sai chữ trong thử nghiệm ban đầu.
- Căn dòng: sai số vài pixel ở góc mã bị khuếch đại ở cuối dòng chữ; so cả dòng mong đợi trong vùng ±8 px để tìm độ dời trước khi đọc từng ô.
# ===== doc_nhan.py — đọc mã 2D, nắn nhãn về hệ chuẩn, OCR + OCV dòng số seri =====
import string
import cv2
import numpy as np
# Bản demo dùng QR của OpenCV thay cho DataMatrix. Hai bộ dò của OpenCV mạnh yếu khác nhau,
# nên đọc theo "bậc thang": thử cách nhanh trước, chỉ thử tiếp khi thất bại (như đầu đọc thương mại).
BO_DO_MA = (cv2.QRCodeDetector(), cv2.QRCodeDetectorAruco())
def doc_ma(anh):
"""Trả (nội dung, 4 góc, bậc đã dùng) hoặc (None, None, None)."""
_, otsu = cv2.threshold(anh, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
for bac, (bo_do, x) in enumerate(((BO_DO_MA[0], anh), (BO_DO_MA[1], anh),
(BO_DO_MA[0], otsu), (BO_DO_MA[1], otsu))):
noi_dung, goc, _ = bo_do.detectAndDecode(x)
if noi_dung and goc is not None:
return noi_dung, goc.reshape(4, 2).astype(np.float32), bac
return None, None, None
def tach_truong(noi_dung):
"""'PN:...;SN:...;LOT:...' → dict. Thiếu trường bắt buộc → None."""
try:
d = dict(p.split(":", 1) for p in noi_dung.split(";"))
except ValueError:
return None
return d if {"PN", "SN", "LOT"} <= d.keys() else None
def nan_nhan(anh, goc_ma):
"""Dùng chính mã 2D làm mốc định vị: phép affine từ 3 góc ảnh → nhãn chuẩn 500 × 200.
Chỉ dùng góc trên-trái, trên-phải, dưới-trái (có mẫu định vị, rất ổn định); góc dưới-phải của QR
là góc ước lượng, sai lệch vài pixel. DataMatrix cũng vậy: hai cạnh liền chữ L là mốc tin cậy."""
chon = [0, 1, 3]
A = cv2.getAffineTransform(goc_ma[chon], GOC_MA_CHUAN[chon].astype(np.float32))
return cv2.warpAffine(anh, A, (W_NHAN, H_NHAN), flags=cv2.INTER_LINEAR, borderValue=235)
def can_dong(nhan, mong_doi, bien=8):
"""Tinh chỉnh vị trí dòng seri: so cả dòng mong đợi (ghép từ mẫu ký tự) trong vùng ±bien px.
Sai số góc mã vài pixel bị khuếch đại ở cuối dòng chữ (xa mã gấp 3 lần cạnh mã)."""
o = CFG["o_seri"]
dong = np.hstack([MAU[c] for c in mong_doi])
vung = nhan[o["y_tren"] - bien:o["y_tren"] + o["cao"] + bien, o["x"] - bien:o["x"] + dong.shape[1] + bien]
_, diem, _, (dx, dy) = cv2.minMaxLoc(cv2.matchTemplate(vung, dong, cv2.TM_CCOEFF_NORMED))
return dx - bien, dy - bien
def doc_seri(nhan, mong_doi):
"""OCR theo ô cố định + OCV so với chuỗi mong đợi (lấy từ mã 2D).
Trả (chuỗi đọc được, NCC từng ký tự, diện tích lỗi in từng ký tự, vị trí kém tin cậy)."""
o, le = CFG["o_seri"], CFG["le_tim_px"]
dx, dy = can_dong(nhan, mong_doi)
doc, ncc, loi, kem = [], [], [], []
for i, loai in enumerate(CFG["dinh_dang_seri"]):
x0, y0 = o["x"] + i * o["buoc"] + dx, o["y_tren"] + dy
o_chu = nhan[y0 - le:y0 + o["cao"] + le, x0 - le:x0 + o["buoc"] + le]
# OCR: ký tự khớp nhất, chỉ trong tập ký tự mà định dạng cho phép ở vị trí này
ung_vien = string.ascii_uppercase if loai == "L" else string.digits
diem = {c: float(cv2.matchTemplate(o_chu, MAU[c], cv2.TM_CCOEFF_NORMED).max()) for c in ung_vien}
xep = sorted(diem, key=diem.get, reverse=True)
doc.append(xep[0])
if diem[xep[0]] - diem[xep[1]] < CFG["bien_ocr"]:
kem.append(i) # hai ứng viên quá sát nhau: không chắc
# OCV: so với ký tự MONG ĐỢI. (1) NCC; (2) diện tích pixel lệch sau khi chỉnh sáng/tương phản
mau = MAU[mong_doi[i]]
r = cv2.matchTemplate(o_chu, mau, cv2.TM_CCOEFF_NORMED)
_, s, _, (px, py) = cv2.minMaxLoc(r)
p = o_chu[py:py + o["cao"], px:px + o["buoc"]].astype(np.float32)
a, b = np.polyfit(p.ravel(), mau.ravel().astype(np.float32), 1) # đưa về cùng thang xám với mẫu
ncc.append(s)
loi.append(int((np.abs(a * p + b - mau) > CFG["lech_muc_xam"]).sum()))
return "".join(doc), ncc, loi, kem
rng = np.random.default_rng(1)
s = tao_seri("2610", "A", 124)
for loi in (None, "cham_muc", "thieu_net", "ma_hong"):
anh = chup(ve_nhan("PSU-12V-60W", s, "L2610A"), rng, loi=loi)
noi_dung, goc, bac = doc_ma(anh)
if noi_dung is None:
print(f"{str(loi):10s}: KHÔNG đọc được mã 2D")
continue
sn = tach_truong(noi_dung)["SN"]
chu, ncc, dt_loi, kem = doc_seri(nan_nhan(anh, goc), mong_doi=sn)
print(f"{str(loi):10s}: mã {sn} | OCR {chu} | NCC thấp nhất {min(ncc):.2f} | "
f"pixel lỗi lớn nhất {max(dt_loi):3d} (ký tự {int(np.argmax(dt_loi))}) | kém tin cậy {kem}")
None : mã VN2610A001246 | OCR VN2610A001246 | NCC thấp nhất 0.96 | pixel lỗi lớn nhất 8 (ký tự 12) | kém tin cậy []
cham_muc : mã VN2610A001246 | OCR VN2610A001246 | NCC thấp nhất 0.90 | pixel lỗi lớn nhất 43 (ký tự 7) | kém tin cậy []
thieu_net : mã VN2610A001246 | OCR VN2610A001246 | NCC thấp nhất 0.76 | pixel lỗi lớn nhất 62 (ký tự 1) | kém tin cậy [8]
ma_hong : KHÔNG đọc được mã 2D
Hai nhãn lỗi in đều được OCR đọc "đúng" chuỗi, nên OCR một mình sẽ cho qua. Chính OCV bắt lỗi: giọt mực để lại 43 pixel lệch ở ký tự vị trí 7 (đếm từ 0), đầu in tắc làm NCC tụt xuống 0,76. Nhãn có mã bị xước không đọc được: không có "đáp án" để xác minh, nên NG.
Phần 3: xác minh với lệnh sản xuất và chống trùng.
# ===== tram_ma.py — một chu kỳ: đọc mã + OCR/OCV + xác minh với lệnh sản xuất + chống trùng =====
import re
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import numpy as np
LENH_SX = {"po": "PO-2610-0815", "pn": "PSU-12V-60W", "lot": "L2610A", "yymm": "2610", "line": "A",
"stt_dau": 100, "stt_cuoi": 599} # 500 hộp, nhận từ MES khi đổi lệnh
MAU_SERI = re.compile(r"^VN(\d{4})([A-Z])(\d{5})(\d)$")
SCHEMA = """
CREATE TABLE IF NOT EXISTS seri_da_dung (seri TEXT PRIMARY KEY, po TEXT NOT NULL, thoi_gian TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS ket_qua (id INTEGER PRIMARY KEY, thoi_gian TEXT, po TEXT, seri_ma TEXT,
seri_chu TEXT, ket_qua TEXT CHECK (ket_qua IN ('OK', 'NG')), ly_do TEXT, ncc_min REAL, loi_in_max INTEGER,
bac_doc INTEGER,
ms REAL);
"""
class TramMa:
def __init__(self, lenh, csdl):
self.lenh = lenh
self.con = sqlite3.connect(csdl)
self.con.execute("PRAGMA journal_mode=WAL")
self.con.executescript(SCHEMA)
self.stt_cao_nhat = None
self.nhay_coc = [] # các lần số thứ tự nhảy: cảnh báo, không NG
def _xac_minh_ma(self, noi_dung):
"""Kiểm tra nội dung mã với lệnh sản xuất. Trả (lý do NG hoặc None, seri, stt)."""
tr = tach_truong(noi_dung)
if tr is None:
return "noi_dung_ma_sai", None, None
sn, m = tr["SN"], MAU_SERI.match(tr["SN"])
if not m:
return "sai_dinh_dang", sn, None
yymm, line, stt, kt = m.groups()
if tr["PN"] != self.lenh["pn"] or tr["LOT"] != self.lenh["lot"] or (yymm, line) != (
self.lenh["yymm"], self.lenh["line"]):
return "sai_lenh_sx", sn, None
if chu_so_luhn(yymm + stt) != kt:
return "sai_checksum", sn, None
stt = int(stt)
if not self.lenh["stt_dau"] <= stt <= self.lenh["stt_cuoi"]:
return "ngoai_dai_seri", sn, None
return None, sn, stt
def xu_ly(self, anh):
t = [time.perf_counter()]
noi_dung, goc, bac = doc_ma(anh)
t.append(time.perf_counter())
sn = chu = ncc_min = loi_max = None
if noi_dung is None:
ly_do = "khong_doc_duoc_ma"
else:
ly_do, sn, stt = self._xac_minh_ma(noi_dung)
if stt is not None: # theo dõi thứ tự in (chỉ để truy vết)
if self.stt_cao_nhat is not None and stt > self.stt_cao_nhat + 1:
self.nhay_coc.append((self.stt_cao_nhat, stt))
self.stt_cao_nhat = max(stt, self.stt_cao_nhat or stt)
if sn is not None and MAU_SERI.match(sn): # OCR + OCV dòng chữ, kể cả khi đã NG
chu, ncc, dt_loi, kem = doc_seri(nan_nhan(anh, goc), mong_doi=sn)
ncc_min, loi_max = round(min(ncc), 3), max(dt_loi)
if ly_do is None and chu != sn and not kem:
ly_do = "chu_khac_ma" # chữ in rõ ràng nhưng KHÁC nội dung mã
elif ly_do is None and (chu != sn or ncc_min < CFG["nguong_ocv"]
or loi_max > CFG["dien_tich_loi_max"]):
ly_do = "in_kem" # chữ in không đạt chất lượng
t.append(time.perf_counter())
thoi_gian = datetime.now().isoformat(timespec="milliseconds")
if ly_do is None:
try: # chống trùng: khóa chính của SQLite
self.con.execute("INSERT INTO seri_da_dung VALUES (?, ?, ?)", (sn, self.lenh["po"], thoi_gian))
except sqlite3.IntegrityError:
ly_do = "trung_seri"
ms = (time.perf_counter() - t[0]) * 1000
self.con.execute("INSERT INTO ket_qua (thoi_gian, po, seri_ma, seri_chu, ket_qua, ly_do, ncc_min, loi_in_max,"
" bac_doc, ms) VALUES (?,?,?,?,?,?,?,?,?,?)",
(thoi_gian, self.lenh["po"], sn, chu, "OK" if ly_do is None else "NG", ly_do, ncc_min,
loi_max, bac, round(ms, 2)))
self.con.commit()
t.append(time.perf_counter())
return {"ket_qua": "OK" if ly_do is None else "NG", "ly_do": ly_do, "seri": sn, "chu": chu,
"ncc_min": ncc_min, "loi_max": loi_max, "bac": bac,
"ms": {"doc_ma": (t[1] - t[0]) * 1e3, "ocr_ocv": (t[2] - t[1]) * 1e3, "csdl": (t[3] - t[2]) * 1e3}}
for f in ("thu_nghiem.db", "thu_nghiem.db-wal", "thu_nghiem.db-shm"):
Path(f).unlink(missing_ok=True) # chạy lại từ đầu: xóa CSDL thử cũ
tram = TramMa(LENH_SX, "thu_nghiem.db")
rng = np.random.default_rng(2)
pn, lot = LENH_SX["pn"], LENH_SX["lot"]
s1, s2 = tao_seri("2610", "A", 100), tao_seri("2610", "A", 101)
thu = [("nhãn tốt", ve_nhan(pn, s1, lot), None),
("nhãn tốt kế tiếp", ve_nhan(pn, s2, lot), None),
("in lại trùng seri", ve_nhan(pn, s2, lot), None),
("chữ in khác mã", ve_nhan(pn, tao_seri("2610", "A", 102), lot, seri_in=tao_seri("2610", "A", 103)), None),
("sai lệnh (PN)", ve_nhan("PSU-24V-60W", tao_seri("2610", "A", 104), lot), None),
("sai chữ số kiểm tra", ve_nhan(pn, "VN2610A001050", lot), None),
("giọt mực", ve_nhan(pn, tao_seri("2610", "A", 106), lot), "cham_muc")]
for ten, nhan, loi in thu:
kq = tram.xu_ly(chup(nhan, rng, loi=loi))
print(f"{ten:20s}: {kq['ket_qua']} {str(kq['ly_do']):14s} mã {kq['seri']} chữ {kq['chu']} "
f"NCC {kq['ncc_min']} lỗi in {kq['loi_max']} px")
print("Thời gian chu kỳ cuối (ms, tùy máy):", {k: round(v, 1) for k, v in kq["ms"].items()})
nhãn tốt : OK None mã VN2610A001006 chữ VN2610A001006 NCC 0.975 lỗi in 0 px
nhãn tốt kế tiếp : OK None mã VN2610A001014 chữ VN2610A001014 NCC 0.969 lỗi in 4 px
in lại trùng seri : NG trung_seri mã VN2610A001014 chữ VN2610A001014 NCC 0.967 lỗi in 3 px
chữ in khác mã : NG chu_khac_ma mã VN2610A001022 chữ VN2610A001030 NCC 0.742 lỗi in 114 px
sai lệnh (PN) : NG sai_lenh_sx mã VN2610A001048 chữ VN2610A001048 NCC 0.97 lỗi in 3 px
sai chữ số kiểm tra : NG sai_checksum mã VN2610A001050 chữ VN2610A001050 NCC 0.97 lỗi in 4 px
giọt mực : NG in_kem mã VN2610A001063 chữ VN2610A001063 NCC 0.855 lỗi in 47 px
Thời gian chu kỳ cuối (ms, tùy máy): {'doc_ma': 20.7, 'ocr_ocv': 17.2, 'csdl': 0.9}
Nhãn "chữ in khác mã" có chữ in sắc nét nhưng là seri của hộp kế tiếp: OCR đọc chắc chắn một chuỗi khác, nên lý do là chu_khac_ma, phân biệt với in_kem. Nhãn sai chữ số kiểm tra bị chặn ngay từ nội dung mã, trước khi tốn thời gian OCR. Dòng thời gian tùy máy.
6.5. Trên máy của bạn: DataMatrix và Tesseract/PaddleOCR
Hai khối dưới đây là phần thay thế cho trạm thật, viết theo cùng cách dùng như bài đọc mã trên bao bì và bài OCR với Tesseract. Chúng không chạy trong môi trường thử của bài (thiếu libdmtx và Tesseract), hãy chạy trên máy bạn sau khi cài.
# (minh họa) — chạy trên máy bạn sau khi cài: pip install pylibdmtx (+ libdmtx của hệ điều hành)
import cv2
import numpy as np
from pylibdmtx.pylibdmtx import decode as dm_decode
from pylibdmtx.pylibdmtx import encode as dm_encode
# 1) Sinh nhãn thử có DataMatrix: thay hai dòng QR.encode/resize trong sinh_nhan.py
enc = dm_encode("PN:PSU-12V-60W;SN:VN2610A001238;LOT:L2610A".encode("utf-8"))
dm = np.frombuffer(enc.pixels, np.uint8).reshape(enc.height, enc.width, 3)[:, :, 0] # ảnh RGB → xám
print("Kích thước ảnh mã (px):", dm.shape)
# 2) Thay doc_ma(): đọc DataMatrix trong vùng quan tâm, luôn đặt timeout
def doc_ma_dm(anh_xam):
kq = dm_decode(anh_xam, timeout=100, max_count=1) # timeout tính bằng mili giây
if not kq:
return None, None
r = kq[0]
x, y, w, h = r.rect.left, r.rect.top, r.rect.width, r.rect.height
y = anh_xam.shape[0] - y - h # libdmtx đặt gốc tọa độ ở dưới-trái
return r.data.decode("utf-8", "replace"), (x, y, w, h)
pylibdmtx trả về nội dung và một hình chữ nhật rect (vị trí, kích thước) của mã, không trả đủ 3 góc mốc như bộ đọc QR của OpenCV. Để nắn nhãn khi nhãn xoay, bạn có hai cách: tìm hướng của mã bằng minAreaRect trên vùng mã quanh rect rồi xác định cạnh chữ L liền (cạnh có mức đen liên tục), hoặc định vị nhãn bằng template matching như dự án đọc số seri trên linh kiện. Các đầu đọc và thư viện thương mại thường trả đủ góc và hướng của mã. Luôn đặt timeout và đọc trong vùng quan tâm nhỏ: libdmtx có thể rất chậm trên ảnh lớn không có mã.
# (minh họa) — cần cài chương trình Tesseract + pip install pytesseract (xem bài OCR với Tesseract)
import cv2
import pytesseract
WL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def doc_seri_tesseract(nhan):
"""Thay phần OCR trong doc_seri(): cắt cả dòng seri trên nhãn đã nắn, phóng to, nhị phân, đọc 1 dòng."""
o = CFG["o_seri"]
roi = nhan[o["y_tren"] - 6:o["y_tren"] + o["cao"] + 6, o["x"] - 8:o["x"] + o["so_ky_tu"] * o["buoc"] + 8]
roi = cv2.resize(roi, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
_, th = cv2.threshold(roi, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
chu = pytesseract.image_to_string(th, config=f"--psm 7 -c tessedit_char_whitelist={WL}")
return chu.strip().replace(" ", "")
# PaddleOCR 3.x (khi chữ khó: nền phức tạp, cong, nghiêng nhiều)
from paddleocr import PaddleOCR
ocr = PaddleOCR(use_doc_orientation_classify=False, use_doc_unwarping=False, use_textline_orientation=False)
for res in ocr.predict("dong_seri.png"): # ảnh dòng seri đã cắt
res.print() # văn bản, điểm tin cậy, tọa độ
Kết quả Tesseract/PaddleOCR đi tiếp qua bước sửa nhầm lẫn theo định dạng (bảng TO_DIGIT/TO_ALPHA của dự án đọc số seri), rồi vào đúng các bước xác minh của TramMa. OCV (NCC và diện tích lỗi in theo mẫu ký tự cắt từ nhãn thật) vẫn giữ, vì OCR không cho biết chữ in đẹp hay xấu.
7. Kiểm thử và nghiệm thu
7.1. Bộ nhãn vàng 300 hộp
Bộ đánh giá mô phỏng 300 hộp liên tiếp của lệnh sản xuất, với 8 loại lỗi rải ở các vị trí cố định (trạm không biết), 18 nhãn mờ tương phản thấp trong nhóm nhãn tốt để thử overkill, và 4 lần máy in bỏ qua một số thứ tự (không phải lỗi, chỉ cảnh báo). Mỗi lần chạy xóa CSDL đánh giá cũ để kết quả lặp lại được.
# ===== danh_gia.py — bộ nhãn vàng 300 hộp: tỉ lệ đọc, escape, overkill, đúng lý do, cycle time =====
from collections import Counter
from pathlib import Path
import numpy as np
for f in ("danh_gia.db", "danh_gia.db-wal", "danh_gia.db-shm"):
Path(f).unlink(missing_ok=True) # mỗi lần đánh giá bắt đầu từ CSDL trống
# Kịch bản lỗi theo vị trí trong dòng hộp (trạm không biết); còn lại là nhãn tốt
KICH_BAN = {}
for loai, vi_tri in {"ma_hong": [12, 61, 133, 190, 251, 288], "cham_muc": [20, 47, 99, 150, 177, 222, 260, 295],
"thieu_net": [33, 88, 141, 199, 233, 270], "chu_sai": [26, 77, 128, 166, 214, 281],
"trung": [40, 93, 157, 208, 243, 298], "sai_lenh": [54, 118, 186, 276],
"checksum": [66, 124, 227, 264], "ngoai_dai": [72, 146, 238, 284],
"nhay": [83, 171, 217, 292]}.items():
for i in vi_tri:
KICH_BAN[i] = loai
for i in range(5, 300, 15):
KICH_BAN.setdefault(i, "mo") # nhãn mờ, tương phản thấp nhưng vẫn đạt: thử overkill
LY_DO_DUNG = {"ma_hong": "khong_doc_duoc_ma", "cham_muc": "in_kem", "thieu_net": "in_kem",
"chu_sai": "chu_khac_ma", "trung": "trung_seri", "sai_lenh": "sai_lenh_sx",
"checksum": "sai_checksum", "ngoai_dai": "ngoai_dai_seri"}
rng = np.random.default_rng(10)
tram = TramMa(LENH_SX, "danh_gia.db")
pn, lot = LENH_SX["pn"], LENH_SX["lot"]
stt, da_ok = LENH_SX["stt_dau"], [] # da_ok: seri của các hộp đã được trạm chấp nhận
dem, sai_ly_do, t_ms, the_doc = Counter(), [], [], Counter()
for i in range(300):
loai = KICH_BAN.get(i, "tot")
if loai == "nhay":
stt += 1 # máy in bỏ qua một số (nhãn hỏng bị xé bỏ)
seri = tao_seri("2610", "A", stt)
if loai == "trung":
seri = da_ok[-5] # in lại seri của một hộp tốt đã đi qua trước đó
elif loai == "ngoai_dai":
seri = tao_seri("2610", "A", 700 + i)
elif loai == "checksum":
seri = seri[:-1] + str((int(seri[-1]) + 1) % 10)
nhan = ve_nhan("PSU-24V-60W" if loai == "sai_lenh" else pn, seri, lot,
seri_in=tao_seri("2610", "A", stt + 1) if loai == "chu_sai" else None)
if loai not in ("trung", "ngoai_dai"):
stt += 1
kq = tram.xu_ly(chup(nhan, rng, loi=loai if loai in ("ma_hong", "cham_muc", "thieu_net", "mo") else None))
t_ms.append(sum(kq["ms"].values()))
the_doc[kq["bac"]] += 1
mong_doi = "NG" if loai in LY_DO_DUNG else "OK"
if kq["ket_qua"] == "OK":
da_ok.append(seri)
if mong_doi == "OK":
dem["ok_dung" if kq["ket_qua"] == "OK" else "overkill"] += 1
dem["ocr_dung"] += kq["chu"] == seri
dem["doc_ma_tot"] += kq["bac"] is not None
else:
dem["ng_dung" if kq["ket_qua"] == "NG" else "escape"] += 1
if kq["ket_qua"] == "NG" and kq["ly_do"] != LY_DO_DUNG[loai]:
sai_ly_do.append((i, loai, kq["ly_do"]))
so_ok = dem["ok_dung"] + dem["overkill"]
so_ng = dem["ng_dung"] + dem["escape"]
p50, p99 = np.percentile(t_ms, [50, 99])
so_ban_ghi, = tram.con.execute("SELECT COUNT(*) FROM ket_qua").fetchone()
theo_bac = tram.con.execute("SELECT bac_doc, COUNT(*), AVG(ms) FROM ket_qua GROUP BY bac_doc").fetchall()
KQ_DG = {"so_ok": so_ok, "so_ng": so_ng, "escape": dem["escape"], "overkill": dem["overkill"],
"sai_ly_do": len(sai_ly_do), "ocr_dung": dem["ocr_dung"], "p50": p50, "p99": p99,
"so_ban_ghi": so_ban_ghi, "doc_ma_tot": dem["doc_ma_tot"]}
print(f"Nhãn mong đợi OK: {so_ok} (có {list(KICH_BAN.values()).count('mo')} nhãn mờ) → đọc được mã {dem['doc_ma_tot']}, OCR đúng hoàn toàn {dem['ocr_dung']}, "
f"overkill {dem['overkill']}")
print(f"Nhãn mong đợi NG: {so_ng} → escape {dem['escape']}, sai lý do {len(sai_ly_do)} {sai_ly_do[:3]}")
print("Bậc đọc mã đã dùng (0 = lần thử đầu):", dict(sorted(the_doc.items(), key=lambda kv: str(kv[0]))))
print("Cảnh báo nhảy số thứ tự:", len(tram.nhay_coc), tram.nhay_coc[:4], "...")
print(tram.con.execute("SELECT ly_do, COUNT(*) FROM ket_qua WHERE ket_qua = 'NG' GROUP BY ly_do "
"ORDER BY COUNT(*) DESC").fetchall())
print(f"Thời gian xử lý (tùy máy): p50 {p50:.0f} ms, p99 {p99:.0f} ms, lớn nhất {max(t_ms):.0f} ms")
print(" trung bình theo bậc đọc mã:", ", ".join(f"bậc {b}: {tb:.0f} ms ({n} nhãn)" for b, n, tb in theo_bac))
Nhãn mong đợi OK: 256 (có 18 nhãn mờ) → đọc được mã 256, OCR đúng hoàn toàn 256, overkill 0
Nhãn mong đợi NG: 44 → escape 0, sai lý do 0 []
Bậc đọc mã đã dùng (0 = lần thử đầu): {0: 266, 1: 27, 2: 1, None: 6}
Cảnh báo nhảy số thứ tự: 18 [(111, 113), (152, 154), (159, 161), (164, 166)] ...
[('in_kem', 14), ('trung_seri', 6), ('khong_doc_duoc_ma', 6), ('chu_khac_ma', 6), ('sai_lenh_sx', 4), ('sai_checksum', 4), ('ngoai_dai_seri', 4)]
Thời gian xử lý (tùy máy): p50 50 ms, p99 95 ms, lớn nhất 294 ms
trung bình theo bậc đọc mã: bậc None: 46 ms (6 nhãn), bậc 0: 46 ms (266 nhãn), bậc 1: 68 ms (27 nhãn), bậc 2: 90 ms (1 nhãn)
Cách đọc kết quả:
- Không escape, không overkill, đúng lý do 44/44. 256 nhãn tốt đều đọc được mã và đọc đúng hoàn toàn số seri, kể cả 18 nhãn mờ.
- Bậc thang đọc mã: phần lớn nhãn đọc được ở bậc 0; khoảng một phần mười cần bậc 1. Nếu bỏ bậc thang, chừng đó nhãn tốt sẽ thành overkill. Theo dõi tỉ lệ dùng bậc cao theo ca là cách phát hiện sớm nhãn in xấu dần hoặc camera bẩn, trước khi tỉ lệ đọc giảm.
- 18 cảnh báo nhảy số: 4 lần máy in bỏ số thật, và 14 lần số kế tiếp nhảy vì nhãn trước đó bị loại trước khi trạm xác định được số thứ tự (6 mã hỏng, 4 sai lệnh, 4 sai chữ số kiểm tra). Cảnh báo nhảy số là thông tin truy vết, không phải NG.
- Thời gian tùy máy và tùy tải. Các nhãn cần đọc lại ở bậc cao tốn thêm thời gian, nên ngân sách phải tính theo p99, không theo trung bình. Nếu p99 trên PC của trạm vượt ngân sách, cách đầu tiên là thu nhỏ vùng đọc mã quanh vị trí nhãn dự kiến, sau đó mới tính tới xử lý song song nhiều hộp.
7.2. Biên bản nghiệm thu
# ===== bao_cao.py — bảng nghiệm thu trạm đọc mã =====
import numpy as np
# R8: "khởi động lại phần mềm" = mở đối tượng TramMa mới trên cùng CSDL, đưa lại một seri đã dùng
tram.con.close()
tram_moi = TramMa(LENH_SX, "danh_gia.db")
kq_r8 = tram_moi.xu_ly(chup(ve_nhan(LENH_SX["pn"], da_ok[0], LENH_SX["lot"]), np.random.default_rng(11)))
so_tot = KQ_DG["so_ok"]
hang = [
("R1", "Đọc được mã trên nhãn tốt", "100 %", f"{KQ_DG['doc_ma_tot']}/{so_tot}", KQ_DG["doc_ma_tot"] == so_tot),
("R2", "OCR đúng hoàn toàn trên nhãn tốt", "100 %", f"{KQ_DG['ocr_dung']}/{so_tot}",
KQ_DG["ocr_dung"] == so_tot),
("R3", "Escape trên nhãn lỗi", "0", f"{KQ_DG['escape']}/{KQ_DG['so_ng']}", KQ_DG["escape"] == 0),
("R4", "Overkill trên nhãn tốt", "≤ 0,5 %", f"{KQ_DG['overkill']}/{so_tot}",
KQ_DG["overkill"] <= 0.005 * so_tot),
("R5", "NG đúng lý do", "100 %", f"{KQ_DG['so_ng'] - KQ_DG['escape'] - KQ_DG['sai_ly_do']}/{KQ_DG['so_ng']}",
KQ_DG["sai_ly_do"] == 0),
("R6", "Mọi hộp có bản ghi truy vết", "300", str(KQ_DG["so_ban_ghi"]), KQ_DG["so_ban_ghi"] == 300),
("R7", "Thời gian xử lý p99 (*)", f"≤ {CFG['ngan_sach_ms']} ms", f"{KQ_DG['p99']:.0f} ms",
KQ_DG["p99"] <= CFG["ngan_sach_ms"]),
("R8", "Trùng seri sau khởi động lại", "NG trùng", f"{kq_r8['ket_qua']} {kq_r8['ly_do']}",
kq_r8["ly_do"] == "trung_seri"),
]
for ma, muc, yc, kq, dat in hang:
print(f"{ma} {muc:34s} {yc:9s} {kq:9s} {'ĐẠT' if dat else 'KHÔNG ĐẠT'}")
print(f"Kết luận: {'ĐẠT' if all(h[-1] for h in hang) else 'KHÔNG ĐẠT'} ({sum(h[-1] for h in hang)}/{len(hang)});"
f" (*) tùy máy, p50 {KQ_DG['p50']:.0f} ms")
R1 Đọc được mã trên nhãn tốt 100 % 256/256 ĐẠT
R2 OCR đúng hoàn toàn trên nhãn tốt 100 % 256/256 ĐẠT
R3 Escape trên nhãn lỗi 0 0/44 ĐẠT
R4 Overkill trên nhãn tốt ≤ 0,5 % 0/256 ĐẠT
R5 NG đúng lý do 100 % 44/44 ĐẠT
R6 Mọi hộp có bản ghi truy vết 300 300 ĐẠT
R7 Thời gian xử lý p99 (*) ≤ 150 ms 95 ms ĐẠT
R8 Trùng seri sau khởi động lại NG trùng NG trung_seri ĐẠT
Kết luận: ĐẠT (8/8); (*) tùy máy, p50 50 ms
R7 tùy máy và tải lúc chạy; ở một lần chạy khác trong lúc máy đang bận, p99 của bài vọt lên khoảng 300 ms dù trung vị gần như không đổi. Đó chính là lý do đo p99 trên đúng PC của trạm, tắt các phần mềm nền không cần thiết, và thử cả lúc trạm đang ghi dữ liệu.
7.3. Lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Tỉ lệ đọc mã thấp dù mã in đẹp | Module quá nhỏ trên ảnh, nhòe chuyển động, một bộ đọc duy nhất | Chốt px/module khi chọn quang học; phơi sáng ngắn; bậc thang thử lại |
| OCR sai ở cuối dòng chữ, đầu dòng đúng | Sai số góc mốc khuếch đại theo khoảng cách | Chỉ dùng góc tin cậy; căn dòng trước khi đọc ô |
| "0"/"O", "8"/"B" nhầm lẫn | Không dùng định dạng | Giới hạn tập ký tự theo vị trí |
| Chữ mất nét vẫn OK | Chỉ so chuỗi OCR với mã | Thêm OCV: NCC và diện tích lỗi in từng ký tự |
| Trùng seri lọt sau khi khởi động lại phần mềm | Danh sách seri đã dùng giữ trong RAM | Khóa chính trong CSDL, kiểm tra bằng INSERT |
| Hộp làm lại bị báo trùng oan | Ghi seri đã dùng trước khi biết hộp OK | Chống trùng là bước cuối, chỉ ghi khi mọi kiểm tra khác đạt |
8. Sản phẩm bàn giao cho portfolio
- README: bài toán trong một câu ("Xác minh nhãn 150 hộp/phút: mã 2D + số seri, chống trùng, p99 < 150 ms"), ảnh nhãn có vẽ khung mã, các ô chữ và điểm OCV, bảng kết quả R1–R8.
- Bảng "demo dùng gì thay cho gì" như mục 6.1 và kết quả chạy với DataMatrix + Tesseract (hoặc PaddleOCR) trên máy bạn. Trung thực về thành phần thay thế được đánh giá cao hơn là che giấu.
- Biểu đồ phân bố NCC nhỏ nhất và diện tích lỗi in của nhãn tốt và nhãn lỗi, có vạch ngưỡng: chứng minh ngưỡng được chọn từ dữ liệu.
- Video 2 phút: chạy bộ 300 nhãn, dừng ở một nhãn trùng seri và một nhãn chữ khác mã, mở CSDL cho thấy truy vết; kết thúc bằng bảng nghiệm thu.
- Phỏng vấn: kể ba lỗi bạn tìm ra nhờ bộ đánh giá (một bộ đọc không đủ; góc thứ tư không tin được; sai số khuếch đại cuối dòng) và cách bạn chứng minh đã sửa.
9. Tự đánh giá
| Tiêu chí | Chưa đạt | Đạt | Tốt |
|---|---|---|---|
| Đọc mã | Một lần đọc, không timeout | Bậc thang thử lại, có timeout, ghi bậc đã dùng | Thống kê bậc theo ca, cảnh báo khi tỉ lệ bậc cao tăng |
| OCR/OCV | Chỉ so chuỗi OCR với mã | OCV từng ký tự, phân biệt chữ khác mã và in kém | Ngưỡng chọn từ phân bố dữ liệu, có biểu đồ; chạy được với Tesseract/PaddleOCR |
| Xác minh | Chỉ kiểm định dạng | Lệnh SX, Luhn, dải seri, trùng trong CSDL | Cảnh báo nhảy số, đổi lệnh SX từ file MES, kiểm thử tự động cho từng quy tắc |
| Tốc độ | Không đo | p50/p99 từng bước so với ngân sách | Thử dưới tải, có phương án khi vượt ngân sách |
| Đánh giá | Vài ảnh thử tay | Bộ nhãn vàng có đáp án, escape/overkill/lý do | Biên bản tự sinh, giải thích giới hạn thống kê |
| Trung thực kỹ thuật | Không nói phần demo thay thế | Bảng thay thế rõ ràng | Có kết quả chạy bản thật trên máy mình |
10. Mở rộng
- GS1: đổi nội dung mã sang cấu trúc GS1 với các mã định danh ứng dụng (AI) như (01) GTIN, (21) số seri, (10) số lô, (17) hạn dùng, và viết bộ tách trường tương ứng.
- Trả kết quả cho PLC bằng Modbus như Capstone 1, thêm 7 thanh ghi chứa số seri dạng ASCII (2 ký tự mỗi thanh ghi) để PLC chuyển cho MES.
- Xử lý song song: một thread chụp, một thread xử lý, hàng đợi giới hạn; đo lại p99 và độ trễ từ trigger tới kết quả.
- Mẫu ký tự từ nhãn thật: viết công cụ cắt và lưu mẫu từng ký tự từ 20 nhãn in thật, lấy trung bình, thay cho mẫu vẽ bằng font.
Bài 1. Tính chữ số kiểm tra Luhn cho phần số 261000123 bằng tay, rồi kiểm lại bằng chu_so_luhn. Luhn có phát hiện được lỗi đổi chỗ hai chữ số kề nhau "00123" → "00213" không?
Xem đáp án
Từ phải sang trái, nhân đôi các chữ số ở vị trí lẻ (sát chữ số kiểm tra): 3→6, 2, 1→2, 0, 0→0, 0, 1→2, 6, 2→4. Tổng = 6 + 2 + 2 + 0 + 0 + 0 + 2 + 6 + 4 = 22, chữ số kiểm tra = (10 − 2) mod 10 = 8, đúng như seri mẫu VN2610A001238. Với "00213": các chữ số 3→6, 1, 2→4, 0, 0, 0, 1→2, 6, 2→4, tổng 23, chữ số kiểm tra 7 ≠ 8, nên phát hiện được. Luhn phát hiện mọi lỗi sai một chữ số và hầu hết lỗi đổi chỗ hai chữ số kề nhau (trừ cặp 09 ↔ 90).
Bài 2. Dây chuyền tăng lên 200 hộp/phút. Ngân sách 150 ms còn hợp lý không? Nếu p99 đo được là 110 ms nhưng có 0,3% chu kỳ vượt 300 ms do ghi đĩa, bạn làm gì?
Xem đáp án
200 hộp/phút = 300 ms/hộp. 150 ms vẫn để lại một nửa cho truyền ảnh, giao tiếp và dao động, nên vẫn hợp lý. Nhưng 0,3% chu kỳ vượt 300 ms nghĩa là cứ khoảng 330 hộp lại có một hộp không có kết quả kịp: không chấp nhận được. Chuyển ghi CSDL và ảnh sang thread nền với hàng đợi giới hạn (như dự án trạm vision mô phỏng), đo lại; đồng thời PLC phải coi "không có kết quả đúng hạn" là NG.
Bài 3. Vì sao trạm đọc mã trước rồi dùng nội dung mã làm "đáp án" cho OCV, mà không làm ngược lại?
Xem đáp án
Mã 2D có sửa lỗi Reed-Solomon và kiểm tra tính toàn vẹn khi giải mã: đọc được thì gần như chắc chắn đúng. OCR không có cơ chế đó, có thể đọc sai mà vẫn "tự tin". Dùng chuỗi đáng tin hơn làm chuẩn để kiểm chuỗi kém tin hơn. Nếu mã không đọc được, trạm không có chuẩn, nên NG chứ không "tin tạm" OCR.
Tóm tắt
- Một chu kỳ: đọc mã (bậc thang thử lại) → xác minh nội dung với lệnh SX → nắn nhãn theo mã → OCR + OCV → chống trùng bằng khóa chính CSDL → ghi truy vết.
- Mã 2D vừa là mốc định vị vừa là đáp án cho OCV; chỉ dùng các góc mốc tin cậy, căn dòng trước khi đọc ô.
- OCR đọc đúng chưa đủ: OCV từng ký tự (NCC, diện tích lỗi in) mới bắt được chữ mất nét, giọt mực.
- Phân biệt NG theo lý do: chữ khác mã (lỗi dữ liệu) khác chữ in kém (lỗi đầu in); chống trùng là bước cuối.
- Tốc độ đánh giá bằng p99 trên đúng máy của trạm; nhãn cần đọc lại là nhãn chậm.
- Bản demo dùng QR và OCR theo mẫu thay cho DataMatrix và Tesseract; trên máy bạn, thay đúng hai hàm đó và giữ nguyên bộ đánh giá.
Dự án tiếp theo: Capstone 3: kiểm tra lỗi bề mặt bằng AI, trả kết quả PLC, nơi quyết định OK/NG đến từ một mô hình học máy chứ không từ quy tắc.


