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

Xuất mô hình PyTorch sang ONNX và kiểm tra kết quả khớp PyTorch

Xuất PyTorch và YOLO sang ONNX, chạy bằng ONNX Runtime, đo sai số trên nhiều ảnh, bắt lỗi tiền xử lý và lưu thẻ mô hình có mã băm.

Xuất mô hình PyTorch sang ONNX và kiểm tra kết quả khớp PyTorch

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

  • Xuất mô hình PyTorch, YOLO sang ONNX
  • Chạy suy luận bằng ONNX Runtime trong Python
  • So sánh sai số đầu ra với PyTorch

Nên học trước: Dự án chặng 12: phát hiện lỗi bề mặt bằng PatchCore

Nội dung bài viết
  1. 1. Quy trình: từ file .pt tới file .onnx "đã kiểm chứng"
  2. 2. Chuẩn bị: dữ liệu, tiền xử lý và một mô hình nhỏ
  3. 3. model.eval(): một dòng quyết định kết quả
  4. 4. Xuất ONNX bằng torch.onnx.export
  5. 5. Kiểm tra file: onnx.checker và đầu vào/đầu ra
  6. 6. Chạy suy luận bằng ONNX Runtime trong Python
  7. 7. So sánh với PyTorch trên N ảnh
  8. 8. Tiền xử lý: nguồn sai lệch số một
  9. 9. Xuất YOLO sang ONNX
  10. 10. Thẻ mô hình (model card) và mã băm
  11. 11. Lỗi thường gặp
  12. 12. Bài tập
  13. Tóm tắt

Ở dự án "Phân loại ảnh OK/NG bằng transfer learning với PyTorch", bước cuối là một dòng torch.onnx.export. Dòng đó tạo ra file, nhưng chưa chứng minh file đó cho cùng kết quả với mô hình bạn đã đánh giá. Trên máy tính công nghiệp, mô hình sẽ chạy bằng ONNX Runtime (trong Python, C# hay C++), với một đoạn tiền xử lý viết lại ở nơi khác. Chỉ cần một chi tiết lệch (quên model.eval(), đảo kênh màu, quên chuẩn hóa) là tỷ lệ lọt lỗi tăng vọt mà không có thông báo lỗi nào. Bài này dạy quy trình xuất ONNX có kiểm chứng: xuất, kiểm tra file, chạy bằng ONNX Runtime, so sánh số với PyTorch trên nhiều ảnh, và lưu một "thẻ mô hình" (model card) đi kèm file. Đây là bài mở đầu chặng Deployment; bài sau đưa đúng file này vào ứng dụng C#.

Mục tiêu cuối bàiXuất được mô hình PyTorch (và YOLO) sang ONNX với batch động; chạy suy luận bằng ONNX Runtime trong Python; chứng minh bằng số rằng ONNX cho cùng kết quả với PyTorch trên N ảnh; đóng gói file ONNX kèm thẻ mô hình JSON (đặc tả đầu vào, opset, mã băm).

1. Quy trình: từ file .pt tới file .onnx "đã kiểm chứng"

ONNX (Open Neural Network Exchange) là định dạng file mô tả đồ thị tính toán của mạng nơ-ron (các phép Conv, Relu, Gemm…) cùng trọng số. Nhiều runtime đọc được ONNX: ONNX Runtime (CPU, GPU, DirectML), OpenVINO, TensorRT. Mỗi phiên bản bộ phép toán gọi là một opset; runtime cũ chỉ hiểu tới một opset nhất định, nên opset là thông tin bắt buộc phải ghi lại.

Mô hình đã huấn luyện (.pt)
   │ 1. model.eval()
   │ 2. torch.onnx.export(...)            → ok_ng.onnx
   │ 3. onnx.checker + in đầu vào/đầu ra   → đúng tên, shape, kiểu?
   │ 4. ONNX Runtime chạy N ảnh thật       → so với PyTorch: sai số, tỷ lệ khớp quyết định
   │ 5. Lưu bộ ảnh vàng + kết quả mong đợi → để kiểm tra phía C#/C++
   └ 6. Thẻ mô hình JSON (opset, tiền xử lý, ngưỡng, SHA-256)
→ Chỉ file đã qua đủ 6 bước mới được chép sang máy trạm.
pip install torch onnx onnxruntime onnxscript opencv-python numpy
# Bài viết chạy với torch 2.14.1 (CPU), onnx 1.23.2, onnxruntime 1.31.0, onnxscript 0.7.2.
# onnxscript là bắt buộc: exporter mặc định của torch (dynamo) dùng nó để dịch sang ONNX.

2. Chuẩn bị: dữ liệu, tiền xử lý và một mô hình nhỏ

Để bài chạy trong vài giây trên mọi máy, ta tạo dữ liệu giả: tấm kim loại xám 96×96 pixel. Ảnh OK có thể có chấm mực xanh dương do QC đánh dấu (hợp lệ); ảnh NG có vết gỉ màu cam. Hai loại chỉ khác nhau ở màu, nên thứ tự kênh màu rất quan trọng, giống hệt nhiều bài toán thật (dây điện sai màu, nhãn sai màu, vết cháy).

Điểm quan trọng nhất của mục này là hàm tien_xu_ly(): mọi nơi chạy mô hình (huấn luyện, kiểm tra ONNX, ứng dụng trạm) phải làm đúng y các bước này.

import cv2
import numpy as np
import torch
import torch.nn as nn

torch.manual_seed(0)
torch.set_num_threads(4)


def tao_anh(ng, rng):
    """Tấm kim loại 96x96 (BGR). OK có thể có chấm mực xanh của QC, NG có vết gỉ màu cam."""
    anh = np.full((96, 96, 3), 128, np.float32) + rng.normal(0, 8, (96, 96, 3))
    anh += np.linspace(-15, 15, 96)[None, :, None]            # ánh sáng không đều trái-phải
    tam = tuple(int(v) for v in rng.integers(20, 76, 2))
    r = int(rng.integers(5, 10))
    if ng:
        cv2.circle(anh, tam, r, (40, 110, 200), -1)           # gỉ: cam (B thấp, R cao)
    elif rng.random() < 0.6:
        cv2.circle(anh, tam, r, (200, 120, 40), -1)           # mực QC: xanh dương
    return np.clip(anh, 0, 255).astype(np.uint8)


KICH_THUOC = 64
MEAN = np.array([0.485, 0.456, 0.406], np.float32)           # mean/std ImageNet, theo thứ tự R, G, B
STD = np.array([0.229, 0.224, 0.225], np.float32)


def tien_xu_ly(anh_bgr):
    """Ảnh BGR uint8 (H, W, 3) -> float32 (3, 64, 64), đúng như lúc huấn luyện."""
    anh = cv2.resize(anh_bgr, (KICH_THUOC, KICH_THUOC), interpolation=cv2.INTER_AREA)
    anh = cv2.cvtColor(anh, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    anh = (anh - MEAN) / STD
    return np.ascontiguousarray(anh.transpose(2, 0, 1))      # HWC -> CHW


rng = np.random.default_rng(0)
nhan = np.array([i % 2 for i in range(800)])                 # 0 = OK, 1 = NG
anh_goc = [tao_anh(n, rng) for n in nhan]
X = torch.from_numpy(np.stack([tien_xu_ly(a) for a in anh_goc]))
y = torch.from_numpy(nhan).long()
cv2.imwrite("vi_du_ok.png", anh_goc[2])
cv2.imwrite("vi_du_ng.png", anh_goc[1])
print(X.shape, X.dtype, y[:6].tolist())
torch.Size([800, 3, 64, 64]) torch.float32 [0, 1, 0, 1, 0, 1]

Mô hình: 3 khối Conv + BatchNorm + ReLU, rồi Dropout và một lớp Linear ra 2 logit (OK, NG). Hai lớp BatchNorm và Dropout được chọn có chủ ý: chúng là lý do số một khiến người mới quên model.eval() mà gặp rắc rối. Huấn luyện 4 epoch trên 600 ảnh, giữ 200 ảnh làm tập kiểm tra:

class MangOkNg(nn.Module):
    def __init__(self):
        super().__init__()
        self.dac_trung = nn.Sequential(
            nn.Conv2d(3, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.AdaptiveAvgPool2d(1), nn.Flatten())
        self.dau = nn.Sequential(nn.Dropout(0.3), nn.Linear(32, 2))

    def forward(self, x):
        return self.dau(self.dac_trung(x))


model = MangOkNg()
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
for ep in range(4):
    model.train()
    perm = torch.randperm(600)
    for i in range(0, 600, 32):
        idx = perm[i:i + 32]
        loss = nn.functional.cross_entropy(model(X[idx]), y[idx])
        opt.zero_grad()
        loss.backward()
        opt.step()
    model.eval()
    with torch.no_grad():
        acc = (model(X[600:]).argmax(1) == y[600:]).float().mean().item()
    print(f"epoch {ep + 1}: loss {loss.item():.3f}, val acc {acc:.3f}")

torch.save(model.state_dict(), "ok_ng.pt")
epoch 1: loss 0.308, val acc 1.000
epoch 2: loss 0.158, val acc 1.000
epoch 3: loss 0.240, val acc 1.000
epoch 4: loss 0.093, val acc 1.000

Giá trị loss có thể lệch chút ít trên CPU hoặc phiên bản thư viện khác. Bài toán giả này dễ nên độ chính xác đạt 100%; với mô hình thật của bạn (ví dụ ResNet18 ở dự án transfer learning), quy trình phía dưới giữ nguyên, chỉ thay MangOkNg và tien_xu_ly.

3. model.eval(): một dòng quyết định kết quả

Hai loại lớp hoạt động khác nhau giữa lúc huấn luyện (train()) và lúc suy luận (eval()):

LớpChế độ trainChế độ eval
BatchNormChuẩn hóa bằng mean/var của batch hiện tại, đồng thời cập nhật running_mean/running_varDùng running_mean/running_var đã học, cố định
DropoutNgẫu nhiên tắt 30% nơ-ron mỗi lần chạyKhông làm gì (cho qua)

Hãy xem điều gì xảy ra nếu chạy suy luận ở chế độ train. Dùng một bản sao để không làm hỏng model:

import copy

torch.manual_seed(0)                      # để Dropout cho cùng kết quả mỗi lần chạy bài
anh_ok = X[600:601]                       # ảnh 600 là ảnh OK (nhãn 0)
ban_sao = copy.deepcopy(model)

with torch.no_grad():
    ban_sao.eval()
    print("eval , lần 1:", ban_sao(anh_ok).numpy().round(3))
    print("eval , lần 2:", ban_sao(anh_ok).numpy().round(3))
    ban_sao.train()
    print("train, lần 1:", ban_sao(anh_ok).numpy().round(3))
    print("train, lần 2:", ban_sao(anh_ok).numpy().round(3))
    ban_sao.eval()
    print("eval sau khi lỡ chạy train:", ban_sao(anh_ok).numpy().round(3))
eval , lần 1: [[ 1.399 -1.744]]
eval , lần 2: [[ 1.399 -1.744]]
train, lần 1: [[-1.322  0.97 ]]
train, lần 2: [[-0.014 -0.372]]
eval sau khi lỡ chạy train: [[ 1.326 -1.66 ]]

Ba bài học từ năm dòng này:

  • Ở chế độ train, lần chạy đầu phân loại ảnh OK thành NG (logit NG lớn hơn logit OK): BatchNorm chuẩn hóa bằng thống kê của đúng một ảnh, hoàn toàn khác thống kê đã học.
  • Cùng một ảnh, hai lần chạy cho hai kết quả khác hẳn nhau (lần hai lại ra OK): Dropout ngẫu nhiên. Mô hình kiểm tra mà không tái lập được kết quả là không thể nghiệm thu.
  • Chỉ chạy ở chế độ train (kể cả trong torch.no_grad()) cũng đã sửa running_mean/running_var: dòng cuối khác dòng đầu. Nếu sau đó bạn lưu mô hình, bạn lưu một mô hình đã bị làm hỏng.
Quên eval() khi xuất và khi so sánhNếu gọi torch.onnx.export khi mô hình đang ở chế độ train, torch 2.14 in cảnh báo UserWarning: Exporting a model while it is in training mode... Calling model.eval() before export is recommended. Trong thử nghiệm với torch 2.14, file ONNX tạo ra vẫn dùng thống kê đã học, nhưng hành vi này phụ thuộc phiên bản và exporter, đừng dựa vào nó. Bẫy hay gặp hơn nằm ở phía so sánh: chạy PyTorch tham chiếu ở chế độ train, thấy sai khác lớn rồi kết luận nhầm là "ONNX bị lỗi". Quy tắc: gọi model.eval() ngay sau khi nạp trọng số, trước mọi việc khác.

4. Xuất ONNX bằng torch.onnx.export

Từ PyTorch 2.9, torch.onnx.export mặc định dùng exporter mới dựa trên torch.export (tham số dynamo=True). Exporter này cần gói onnxscript, trả về một đối tượng ONNXProgram và, nếu bạn truyền tên file, ghi luôn file. Lệnh xuất đầy đủ cho mô hình phân loại:

model = MangOkNg()
model.load_state_dict(torch.load("ok_ng.pt", weights_only=True))
model.eval()                                            # luôn luôn, trước khi xuất

vi_du = torch.from_numpy(tien_xu_ly(anh_goc[0]))[None]  # (1, 3, 64, 64), đi qua đúng tiền xử lý
chuong_trinh = torch.onnx.export(
    model, (vi_du,), "ok_ng.onnx",
    input_names=["images"], output_names=["logits"],
    dynamic_shapes={"x": {0: "batch"}},                 # "x" = tên tham số của forward(); trục 0 động
    opset_version=18,
    external_data=False,                                # trọng số nằm luôn trong file .onnx
    verbose=False,                                      # tắt các dòng tiến trình [torch.onnx] ...
)
print(type(chuong_trinh).__name__)

from pathlib import Path
for p in sorted(Path(".").glob("ok_ng*")):
    print(p.name, p.stat().st_size, "byte")
ONNXProgram
ok_ng.onnx 75657 byte
ok_ng.pt 65572 byte

Ý nghĩa từng tham số (đã kiểm tra trên mã nguồn torch 2.14.1):

Tham sốGiá trị trong bàiGhi chú
args(vi_du,)Tuple đầu vào mẫu. Dùng một ảnh thật đã qua tiền xử lý, không cần đúng batch bạn sẽ chạy.
input_names, output_namesimages, logitsTên mà ứng dụng C#/Python sẽ dùng khi gọi run. Đặt tên rõ ràng, ghi vào thẻ mô hình.
dynamic_shapes{"x": {0: "batch"}}Cách khai báo trục động của exporter mới. Khóa là tên tham số của forward, giá trị là {chỉ số trục: tên trục}. Không khai báo thì mọi trục cố định theo ví dụ (batch = 1).
dynamic_axes(không dùng)Cách cũ, khóa là tên đầu vào/đầu ra. Với exporter mới vẫn chạy nhưng in UserWarning và tự chuyển sang dynamic_shapes; chỉ nên dùng với dynamo=False.
opset_version18Mặc định là 20 trong torch 2.14. Chọn theo runtime đích: runtime cũ trên máy trạm có thể chưa hỗ trợ opset mới. Exporter mới làm việc ở opset 18; chọn thấp hơn 18 thì nó in cảnh báo và thử chuyển đổi phiên bản, có thể không thành công.
external_dataFalseMặc định là True: trọng số được ghi ra file riêng ok_ng.onnx.data cạnh file .onnx. Với mô hình dưới 2 GB (gần như mọi mô hình vision), đặt False để có một file duy nhất.
dynamo(mặc định True)dynamo=False dùng exporter TorchScript cũ, đã bị đánh dấu deprecated (in DeprecationWarning).
verify(không dùng)verify=True cho exporter tự chạy thử bằng ONNX Runtime trên đầu vào mẫu. Tiện, nhưng chỉ một đầu vào; không thay được phép so sánh trên N ảnh ở mục 7.
Bẫy file .onnx.dataVới cài đặt mặc định (external_data=True), exporter tạo hai file. Chép mỗi model.onnx sang máy trạm thì ONNX Runtime báo lỗi không tìm thấy dữ liệu trọng số. Hoặc đặt external_data=False, hoặc luôn chép cả hai file và ghi cả hai mã băm vào thẻ mô hình.

Để so sánh, đây là cách viết cho exporter cũ mà bạn sẽ gặp trong nhiều hướng dẫn trên mạng. Nó vẫn chạy trong torch 2.14 (kèm cảnh báo deprecated), cho cùng đồ thị đầu vào với batch động:

import warnings

import onnx

with warnings.catch_warnings():
    warnings.simplefilter("ignore", DeprecationWarning)
    torch.onnx.export(model, (vi_du,), "ok_ng_cu.onnx", dynamo=False,
                      input_names=["images"], output_names=["logits"],
                      dynamic_axes={"images": {0: "batch"}, "logits": {0: "batch"}},
                      opset_version=18)

dau_vao = onnx.load("ok_ng_cu.onnx").graph.input[0]
print(dau_vao.name, [d.dim_param or d.dim_value for d in dau_vao.type.tensor_type.shape.dim])
images ['batch', 3, 64, 64]

Mã mới nên dùng exporter mặc định với dynamic_shapes. Giữ cách cũ chỉ khi exporter mới thất bại với một mô hình có cấu trúc đặc biệt, và ghi rõ điều đó trong thẻ mô hình.

5. Kiểm tra file: onnx.checker và đầu vào/đầu ra

Trước khi chạy, hãy kiểm tra file hợp lệ theo chuẩn ONNX và in "hợp đồng" của nó: tên, shape, kiểu dữ liệu của đầu vào và đầu ra. Đây chính là những gì ứng dụng phía C# phải tuân theo.

import onnx
import onnxruntime as ort

mo_hinh_onnx = onnx.load("ok_ng.onnx")
onnx.checker.check_model(mo_hinh_onnx, full_check=True)   # lỗi cấu trúc -> ném exception
print("opset:", mo_hinh_onnx.opset_import[0].version, "| ir_version:", mo_hinh_onnx.ir_version,
      "| producer:", mo_hinh_onnx.producer_name, mo_hinh_onnx.producer_version)
print("số node:", len(mo_hinh_onnx.graph.node), sorted({n.op_type for n in mo_hinh_onnx.graph.node}))

sess = ort.InferenceSession("ok_ng.onnx", providers=["CPUExecutionProvider"])
for i in sess.get_inputs():
    print("input ", i.name, i.shape, i.type)
for o in sess.get_outputs():
    print("output", o.name, o.shape, o.type)
print("onnxruntime", ort.__version__, sess.get_providers())
opset: 18 | ir_version: 10 | producer: pytorch 2.14.1+cu130
số node: 11 ['Conv', 'Gemm', 'MaxPool', 'ReduceMean', 'Relu', 'Reshape']
input  images ['batch', 3, 64, 64] tensor(float)
output logits ['batch', 2] tensor(float)
onnxruntime 1.31.0 ['CPUExecutionProvider']

Đọc kết quả:

  • Trục đầu là 'batch' (chuỗi) nghĩa là trục động; các trục còn lại cố định 3×64×64. Kiểu tensor(float) là float32.
  • Đồ thị không còn node BatchNormalization và Dropout: exporter đã gộp BatchNorm vào trọng số Conv (dùng thống kê đã học) và bỏ Dropout vì đây là đồ thị suy luận. Nếu bạn thấy Dropout hay BatchNormalization có thuộc tính training_mode trong đồ thị, hãy nghi ngờ mô hình chưa ở chế độ eval.
  • check_model chỉ kiểm tra cấu trúc hợp lệ, không kiểm tra kết quả đúng. Một file ONNX hoàn toàn hợp lệ vẫn có thể cho kết quả sai nếu tiền xử lý sai.

6. Chạy suy luận bằng ONNX Runtime trong Python

Ba quy tắc: tạo InferenceSession một lần khi khởi động; đầu vào là mảng NumPy float32, layout NCHW, liên tục trong bộ nhớ; đầu ra là list các mảng NumPy theo thứ tự output_names.

def softmax(z):
    e = np.exp(z - z.max(axis=1, keepdims=True))
    return e / e.sum(axis=1, keepdims=True)


anh = cv2.imread("vi_du_ng.png")                    # ảnh BGR như từ camera
x = tien_xu_ly(anh)[None]                           # (1, 3, 64, 64) float32
logits = sess.run(["logits"], {"images": x})[0]
print("logits:", logits.round(3), "| P(NG) =", softmax(logits)[0, 1].round(4))

lo = np.stack([tien_xu_ly(a) for a in anh_goc[600:608]])     # batch 8 nhờ trục động
print("batch 8 ->", sess.run(None, {"images": lo})[0].shape)

for sai, ten in [(x.astype(np.float64), "float64"), (x[:, :, :32, :32], "ảnh 32x32")]:
    try:
        sess.run(None, {"images": sai})
    except Exception as e:
        thong_bao = " ".join(str(e).split())             # gộp nhiều dòng thành một
        print(ten, "->", type(e).__name__, ":", thong_bao.split("INVALID_ARGUMENT : ")[-1][:100])
logits: [[-1.455  0.904]] | P(NG) = 0.9136
batch 8 -> (8, 2)
float64 -> InvalidArgument : Unexpected input data type. Actual: (tensor(double)) , expected: (tensor(float))
ảnh 32x32 -> InvalidArgument : Got invalid dimensions for input: images for the following indices index: 2 Got: 32 Expected: 64 ind

ONNX Runtime không tự đổi float64 sang float32 hay tự resize: nó báo lỗi ngay. Đây là điều tốt. Lỗi nguy hiểm là những lỗi không báo gì, như ở mục 8. Lưu ý tien_xu_ly trả về float32 vì MEAN, STD khai báo float32; nếu bạn viết np.array([0.485, ...]) không ghi kiểu, NumPy tạo float64 và bạn sẽ gặp đúng lỗi trên.

7. So sánh với PyTorch trên N ảnh

Một ảnh khớp chưa nói lên điều gì. Hãy tạo một bộ kiểm tra mới (200 ảnh, chưa dùng khi huấn luyện), chạy cả hai phía trên cùng một tensor đầu vào, rồi đo bốn con số:

rng_kt = np.random.default_rng(1)
nhan_kt = np.array([i % 2 for i in range(200)])
anh_kt = [tao_anh(n, rng_kt) for n in nhan_kt]
dau_vao = np.stack([tien_xu_ly(a) for a in anh_kt])           # (200, 3, 64, 64)

with torch.no_grad():
    ref = model(torch.from_numpy(dau_vao)).numpy()             # PyTorch, model đã eval()
out = sess.run(None, {"images": dau_vao})[0]                   # ONNX Runtime

sai = np.abs(out - ref)
p_ref, p_out = softmax(ref)[:, 1], softmax(out)[:, 1]
print(f"sai khác logit : lớn nhất {sai.max():.2e}, trung bình {sai.mean():.2e}")
print(f"sai khác P(NG) : lớn nhất {np.abs(p_out - p_ref).max():.2e}")
print(f"khớp quyết định: {(out.argmax(1) == ref.argmax(1)).mean() * 100:.1f}% trên {len(out)} ảnh")
print(f"độ chính xác ONNX: {(out.argmax(1) == nhan_kt).mean() * 100:.1f}%")

np.testing.assert_allclose(out, ref, rtol=1e-3, atol=1e-5)    # không đạt -> AssertionError
print("assert_allclose(rtol=1e-3, atol=1e-5): ĐẠT")
sai khác logit : lớn nhất 7.15e-07, trung bình 9.66e-08
sai khác P(NG) : lớn nhất 1.19e-07
khớp quyết định: 100.0% trên 200 ảnh
độ chính xác ONNX: 100.0%
assert_allclose(rtol=1e-3, atol=1e-5): ĐẠT

Sai khác cỡ 10-7 là bình thường: hai thư viện cộng các số float32 theo thứ tự khác nhau (và ONNX đã gộp BatchNorm vào Conv), nên làm tròn khác nhau ở chữ số thứ 7. assert_allclose đạt khi |out - ref| ≤ atol + rtol × |ref| với mọi phần tử; khi không đạt, nó in số phần tử lệch và vị trí lệch lớn nhất, rất tiện để đưa vào script kiểm thử tự động.

Ngưỡng chấp nhận tham khảo (logit/xác suất, cùng đầu vào):

Tình huốngSai khác lớn nhất thường gặpTiêu chí nên dùng
FP32, ONNX Runtime CPU10-7 – 10-5assert_allclose(rtol=1e-3, atol=1e-5) + khớp quyết định 100%
FP32 trên GPU (CUDA, TensorRT, DirectML)10-5 – 10-3atol cỡ 1e-3 + khớp quyết định ≥ 99,9%
FP1610-3 – 10-2So quyết định và chỉ số nghiệm thu (lọt lỗi, loại nhầm) trên tập test
INT8 (lượng tử hóa)có thể tới 10-1Chỉ so ở mức quyết định và chỉ số nghiệm thu; bắt buộc đánh giá lại trên ảnh lỗi thật
Sai khác ≥ 0,1 ở FP32Không phải sai số làm tròn: đó là lỗi (tiền xử lý, eval, sai phiên bản trọng số). Dừng lại và tìm nguyên nhân.

"Khớp quyết định" quan trọng hơn sai khác số: điều PLC nhận được là OK/NG. Nhưng chỉ nhìn khớp quyết định thì dễ bỏ sót lỗi tiền xử lý nhỏ đang âm thầm đẩy điểm tin cậy về gần ngưỡng. Vì vậy luôn báo cả hai.

Ảnh sát ngưỡngVới FP16/INT8, ảnh có P(NG) nằm sát ngưỡng (ví dụ 0,45–0,55) có thể đổi quyết định. Đếm riêng số ảnh như vậy trong tập test; nếu nhiều, cần thêm dữ liệu hoặc xem lại ngưỡng, không phải lỗi của ONNX.

8. Tiền xử lý: nguồn sai lệch số một

Khi kết quả ở trạm khác kết quả lúc phát triển, 9/10 trường hợp nguyên nhân nằm ở tiền xử lý, không phải ở mô hình. Thí nghiệm dưới đây chạy cùng file ONNX trên cùng 200 ảnh, chỉ thay đổi một chi tiết tiền xử lý mỗi lần, rồi so với kết quả tham chiếu ref:

def tien_xu_ly_sai(anh_bgr, loi):
    noi_suy = cv2.INTER_LINEAR if loi == "resize INTER_LINEAR" else cv2.INTER_AREA
    a = cv2.resize(anh_bgr, (KICH_THUOC, KICH_THUOC), interpolation=noi_suy)
    if loi != "quên BGR->RGB":
        a = cv2.cvtColor(a, cv2.COLOR_BGR2RGB)
    a = a.astype(np.float32)
    if loi != "quên chia 255":
        a = a / 255.0
    if loi != "quên mean/std":
        a = (a - MEAN) / STD
    if loi == "reshape thay transpose":
        return a.reshape(3, KICH_THUOC, KICH_THUOC)          # sai: chỉ đổi shape, không đổi thứ tự dữ liệu
    return a.transpose(2, 0, 1)


print(f"{'Biến thể':24s} {'sai khác max':>12s} {'khớp ref':>9s} {'đúng nhãn':>10s}")
for loi in ["đúng như huấn luyện", "resize INTER_LINEAR", "quên mean/std",
            "quên BGR->RGB", "quên chia 255", "reshape thay transpose"]:
    xs = np.ascontiguousarray(np.stack([tien_xu_ly_sai(a, loi) for a in anh_kt]), dtype=np.float32)
    o = sess.run(None, {"images": xs})[0]
    print(f"{loi:24s} {np.abs(o - ref).max():12.3f} {(o.argmax(1) == ref.argmax(1)).mean() * 100:8.1f}%"
          f" {(o.argmax(1) == nhan_kt).mean() * 100:9.1f}%")
Biến thể                 sai khác max  khớp ref  đúng nhãn
đúng như huấn luyện             0.000    100.0%     100.0%
resize INTER_LINEAR             0.092    100.0%     100.0%
quên mean/std                   2.940     59.0%      59.0%
quên BGR->RGB                   5.411     21.0%      21.0%
quên chia 255                4512.459     50.0%      50.0%
reshape thay transpose         12.898     50.0%      50.0%

Không dòng nào báo lỗi: ONNX Runtime vui vẻ chạy mọi biến thể vì shape và kiểu dữ liệu vẫn đúng. Phân tích:

  • Quên BGR→RGB: OpenCV đọc ảnh theo thứ tự BGR, còn PyTorch/torchvision (PIL) quen với RGB. Ở bài này, đảo kênh biến chấm mực xanh (hợp lệ) thành màu cam giống vết gỉ và ngược lại, nên độ đúng rơi xuống 21%, tệ hơn đoán ngẫu nhiên. Trong thực tế, mô hình đọc vết lỗi theo độ sáng ít bị ảnh hưởng hơn, nên lỗi này có thể chỉ làm tăng lọt lỗi vài phần trăm và tồn tại hàng tháng không ai biết.
  • Quên chia 255 / quên mean-std: mô hình nhận dải giá trị nó chưa từng thấy. Kết quả gần như hằng số (một lớp cho mọi ảnh), độ đúng ≈ tỷ lệ của lớp đó.
  • reshape thay transpose: ảnh từ OpenCV có layout HWC (cao, rộng, kênh); mô hình cần CHW. reshape(3, 64, 64) chỉ đổi shape mà không sắp xếp lại dữ liệu, nên mỗi "kênh" là một mớ pixel trộn lẫn. Phải dùng transpose(2, 0, 1).
  • Đổi phép nội suy khi resize: sai khác nhỏ (0,09) nhưng khác 0. Với ảnh sát ngưỡng, nó đủ đổi quyết định. Ghi rõ phép nội suy trong thẻ mô hình; và lưu ý cv2.resize, PIL và torchvision cho kết quả hơi khác nhau ngay cả khi cùng tên "bilinear".

Cách phòng: (1) một hàm tien_xu_ly duy nhất, dùng chung cho huấn luyện, đánh giá và ứng dụng Python; (2) ghi đặc tả tiền xử lý vào thẻ mô hình; (3) xuất một bộ ảnh vàng kèm đầu ra mong đợi để phía C#/C++ tự kiểm tra code tiền xử lý của mình:

import json
from pathlib import Path

vang = Path("bo_anh_vang")
vang.mkdir(exist_ok=True)
mong_doi = []
for i in range(8):                                        # 4 OK + 4 NG
    ten = f"anh_{i:02d}_{'NG' if nhan_kt[i] else 'OK'}.png"
    cv2.imwrite(str(vang / ten), anh_kt[i])               # ảnh gốc BGR, PNG không nén mất dữ liệu
    lg = sess.run(None, {"images": tien_xu_ly(anh_kt[i])[None]})[0][0]
    tong_kenh = tien_xu_ly(anh_kt[i]).sum(axis=(1, 2))      # tổng từng kênh R, G, B của tensor
    mong_doi.append({"file": ten, "logits": [round(float(v), 5) for v in lg],
                     "tong_kenh": [round(float(v), 2) for v in tong_kenh]})
(vang / "mong_doi.json").write_text(json.dumps(mong_doi, indent=1, ensure_ascii=False), encoding="utf-8")
print(len(list(vang.glob("*.png"))), "ảnh;", mong_doi[1])
8 ảnh; {'file': 'anh_01_NG.png', 'logits': [-2.43758, 1.78449], 'tong_kenh': [374.83, 780.43, 1579.52]}

Trường tong_kenh (tổng các phần tử của từng kênh trong tensor đầu vào) là mẹo gỡ lỗi rẻ mà hiệu quả: nếu phía C# ra logit khác, so tong_kenh trước. Khác tổng → lỗi ở tiền xử lý (và kênh nào lệch gợi ý lỗi gì); cùng tổng mà khác logit → lỗi ở cách gọi mô hình. Dùng tổng từng kênh chứ không phải tổng cả tensor: ảnh gần xám có tổng ba kênh gần bằng nhau, nên lỗi đảo kênh gần như không làm đổi tổng chung. Phải lưu ảnh PNG; JPEG nén mất dữ liệu sẽ làm hai phía nhận pixel khác nhau.

9. Xuất YOLO sang ONNX

Với mô hình Ultralytics (YOLO26 và các đời trước), thư viện đã gói sẵn toàn bộ bước xuất. Trên máy của bạn (cần pip install ultralytics và file trọng số), đoạn sau chạy được; trong môi trường viết bài không tải được trọng số nên chỉ để minh họa:

# (minh họa) cần ultralytics và file trọng số best.pt từ bài huấn luyện YOLO26
from ultralytics import YOLO
import onnxruntime as ort

duong_dan = YOLO("best.pt").export(format="onnx", imgsz=640)   # trả về đường dẫn file .onnx
sess_yolo = ort.InferenceSession(duong_dan, providers=["CPUExecutionProvider"])
print([(i.name, i.shape) for i in sess_yolo.get_inputs()])
print([(o.name, o.shape) for o in sess_yolo.get_outputs()])

# So sánh cách nhanh: Ultralytics nạp được cả .pt lẫn .onnx với CÙNG tiền xử lý (letterbox, RGB, /255)
kq_pt = YOLO("best.pt").predict("anh_test.png", conf=0.25, verbose=False)[0].boxes
kq_onnx = YOLO(duong_dan).predict("anh_test.png", conf=0.25, verbose=False)[0].boxes
print(len(kq_pt), len(kq_onnx))

Những điểm cần biết khi xuất YOLO:

  • Kích thước đầu vào cố định theo imgsz (mặc định cố định, thêm dynamic=True nếu cần batch/kích thước động). Ghi imgsz vào thẻ mô hình; ứng dụng phải letterbox (thu nhỏ giữ tỷ lệ, đệm viền) về đúng kích thước đó.
  • Định dạng đầu ra khác nhau theo đời mô hình: YOLOv8/YOLO11 xuất tensor thô cần NMS; YOLO26 là end-to-end nên đầu ra đã là danh sách khung cuối cùng (tọa độ, điểm tin cậy, lớp). Luôn in get_outputs() như trên trước khi viết code giải mã, đừng chép code giải mã của đời khác.
  • So sánh theo khung, không theo mảng: hai phía có thể trả khung theo thứ tự khác nhau. Ghép cặp khung theo IoU (Intersection over Union, tỷ lệ diện tích giao trên diện tích hợp) rồi so lớp và điểm tin cậy.

Hàm ghép cặp khung dưới đây chạy được ngay, dùng cho mọi mô hình phát hiện vật (YOLO, hay mô hình của bạn):

def iou(a, b):
    """a, b: [x1, y1, x2, y2]"""
    rong = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
    cao = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
    giao = rong * cao
    hop = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - giao
    return giao / hop if hop > 0 else 0.0


def so_khop_khung(ds_ref, ds_moi, iou_min=0.9, lech_diem_max=0.02):
    """Mỗi phần tử: (x1, y1, x2, y2, diem, lop). Trả về danh sách vấn đề (rỗng = khớp)."""
    van_de, da_dung = [], set()
    for i, r in enumerate(ds_ref):
        tot, j_tot = 0.0, None
        for j, m in enumerate(ds_moi):
            if j not in da_dung and m[5] == r[5] and iou(r, m) > tot:
                tot, j_tot = iou(r, m), j
        if j_tot is None or tot < iou_min:
            van_de.append(f"khung ref {i} (lớp {r[5]}) không có cặp, IoU tốt nhất {tot:.2f}")
            continue
        da_dung.add(j_tot)
        if abs(ds_moi[j_tot][4] - r[4]) > lech_diem_max:
            van_de.append(f"khung ref {i}: điểm {r[4]:.3f} vs {ds_moi[j_tot][4]:.3f}")
    van_de += [f"khung mới {j} thừa" for j in range(len(ds_moi)) if j not in da_dung]
    return van_de


ref_khung = [(100, 50, 180, 120, 0.91, 0), (300, 200, 340, 260, 0.62, 1)]
onnx_khung = [(300.4, 199.8, 340.1, 260.2, 0.618, 1), (100.2, 50.1, 179.9, 120.3, 0.909, 0)]
print(so_khop_khung(ref_khung, onnx_khung) or "Khớp hoàn toàn")
print(so_khop_khung(ref_khung, onnx_khung[:1] + [(500, 10, 520, 30, 0.3, 0)]))
Khớp hoàn toàn
['khung ref 0 (lớp 0) không có cặp, IoU tốt nhất 0.00', 'khung mới 1 thừa']

Với FP32, tọa độ khung giữa PyTorch và ONNX thường lệch dưới 1 pixel, điểm tin cậy lệch dưới 0,01. Khung nằm sát ngưỡng conf có thể xuất hiện ở phía này mà không ở phía kia; đó là lý do so trên nhiều ảnh và xem xét từng trường hợp lệch, thay vì đòi "khớp 100%" một cách máy móc.

10. Thẻ mô hình (model card) và mã băm

Sáu tháng sau, ai đó hỏi: "file ok_ng.onnx trên máy trạm 3 là bản nào, huấn luyện ngày nào, tiền xử lý ra sao, đã kiểm chứng chưa?". Câu trả lời phải nằm trong một file JSON đi cùng mô hình. Mã băm SHA-256 (dấu vân tay 64 ký tự hex của nội dung file) cho phép kiểm tra file không bị chép nhầm phiên bản hay hỏng khi truyền.

import hashlib
import platform
from datetime import datetime


def sha256(duong_dan):
    h = hashlib.sha256()
    with open(duong_dan, "rb") as f:
        for khoi in iter(lambda: f.read(1 << 20), b""):    # đọc từng 1 MB, không nạp cả file vào RAM
            h.update(khoi)
    return h.hexdigest()


the_mo_hinh = {
    "ten": "ok_ng_gi_set",
    "phien_ban": "1.0.0",
    "ngay_xuat": datetime.now().isoformat(timespec="seconds"),
    "file": "ok_ng.onnx",
    "sha256": sha256("ok_ng.onnx"),
    "nguon": {"file_pt": "ok_ng.pt", "sha256_pt": sha256("ok_ng.pt")},
    "moi_truong": {"python": platform.python_version(), "torch": torch.__version__,
                   "onnx": onnx.__version__, "onnxruntime": ort.__version__},
    "onnx": {"opset": mo_hinh_onnx.opset_import[0].version, "exporter": "torch.onnx.export(dynamo=True)"},
    "dau_vao": {"ten": "images", "shape": ["batch", 3, KICH_THUOC, KICH_THUOC], "kieu": "float32",
                "layout": "NCHW"},
    "tien_xu_ly": {"doc_anh": "BGR uint8 (OpenCV)", "resize": [KICH_THUOC, KICH_THUOC],
                   "noi_suy": "INTER_AREA", "thu_tu_kenh": "RGB", "chia": 255.0,
                   "mean": MEAN.tolist(), "std": STD.tolist()},
    "dau_ra": {"ten": "logits", "shape": ["batch", 2], "lop": ["OK", "NG"],
               "ghi_chu": "logit chưa softmax"},
    "nguong_ng": 0.5,
    "kiem_chung": {"so_anh": int(len(out)), "sai_khac_logit_max": float(sai.max()),
                   "khop_quyet_dinh": float((out.argmax(1) == ref.argmax(1)).mean()),
                   "bo_anh_vang": "bo_anh_vang/mong_doi.json"},
}
Path("ok_ng.model.json").write_text(json.dumps(the_mo_hinh, indent=2, ensure_ascii=False), encoding="utf-8")
print(the_mo_hinh["sha256"][:16], "...", the_mo_hinh["onnx"], the_mo_hinh["kiem_chung"]["khop_quyet_dinh"])
716384f304a7c57b ... {'opset': 18, 'exporter': 'torch.onnx.export(dynamo=True)'} 1.0

Mã băm của bạn sẽ khác (trọng số phụ thuộc máy, và file ONNX chứa cả phiên bản exporter). Phía ứng dụng, trước khi tạo session, đọc thẻ mô hình và từ chối chạy nếu mã băm hoặc đặc tả đầu vào không khớp. Lỗi phát hiện lúc khởi động rẻ hơn rất nhiều so với một ca sản xuất phân loại sai:

def nap_mo_hinh(duong_dan_the):
    the = json.loads(Path(duong_dan_the).read_text(encoding="utf-8"))
    file_onnx = Path(duong_dan_the).parent / the["file"]
    if sha256(file_onnx) != the["sha256"]:
        raise RuntimeError(f"{file_onnx.name}: SHA-256 không khớp thẻ mô hình, từ chối nạp")
    s = ort.InferenceSession(str(file_onnx), providers=["CPUExecutionProvider"])
    vao = s.get_inputs()[0]
    if vao.name != the["dau_vao"]["ten"] or vao.shape[1:] != the["dau_vao"]["shape"][1:]:
        raise RuntimeError(f"Đầu vào {vao.name} {vao.shape} khác thẻ mô hình")
    return s, the


s2, the2 = nap_mo_hinh("ok_ng.model.json")
print("Nạp OK:", the2["ten"], the2["phien_ban"])

with open("ok_ng.onnx", "ab") as f:          # giả lập file bị chép nhầm/hỏng: thêm 1 byte
    f.write(b"\0")
try:
    nap_mo_hinh("ok_ng.model.json")
except RuntimeError as e:
    print("Lỗi:", e)
Nạp OK: ok_ng_gi_set 1.0.0
Lỗi: ok_ng.onnx: SHA-256 không khớp thẻ mô hình, từ chối nạp

Chỉ một byte khác là mã băm khác hoàn toàn. Dự án cuối chặng sẽ dùng đúng cơ chế này để cập nhật mô hình an toàn trên máy trạm.

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

Triệu chứngNguyên nhân hay gặpCách xử lý
ONNX khác PyTorch nhiều, kết quả PyTorch thay đổi mỗi lần chạyPyTorch tham chiếu đang ở chế độ trainmodel.eval() ngay sau khi nạp trọng số
Python khớp, C# lệch nhiềuTiền xử lý khác: BGR/RGB, chia 255, mean/std, HWC/CHWSo tong_kenh với bộ ảnh vàng; viết lại từ thẻ mô hình
Chạy batch 1 được, batch 8 báo lỗi dimensionXuất không khai báo trục độngdynamic_shapes={"x": {0: "batch"}}, kiểm tra bằng get_inputs()
Máy trạm báo không tìm thấy dữ liệu trọng sốThiếu file .onnx.dataexternal_data=False hoặc chép đủ hai file
Runtime trên máy trạm báo opset không hỗ trợOpset mới hơn khả năng của runtimeNâng runtime, hoặc xuất lại với opset_version thấp hơn và kiểm chứng lại
Khớp trên ảnh tổng hợp, lệch trên ảnh thậtẢnh thật qua JPEG/khác cỡ, nội suy khácBộ ảnh vàng là ảnh thật dạng PNG, đúng kích thước camera
Không biết file trên máy trạm là bản nàoKhông có thẻ mô hình, không có mã bămThẻ mô hình JSON + kiểm tra SHA-256 khi nạp

12. Bài tập

Bài 1. Xuất model sang ok_ng_b1.onnx nhưng không khai báo dynamic_shapes. In shape đầu vào bằng ONNX Runtime và thử chạy batch 8. Sau đó viết hàm chay_tung_anh(sess, lo) chạy lần lượt từng ảnh để vẫn xử lý được batch 8. Khi nào cách này chấp nhận được?

Xem đáp án
torch.onnx.export(model, (vi_du,), "ok_ng_b1.onnx", input_names=["images"], output_names=["logits"],
                  opset_version=18, external_data=False, verbose=False)
s_b1 = ort.InferenceSession("ok_ng_b1.onnx", providers=["CPUExecutionProvider"])
print(s_b1.get_inputs()[0].shape)
lo8 = dau_vao[:8]
try:
    s_b1.run(None, {"images": lo8})
except Exception as e:
    print("batch 8:", str(e).splitlines()[0][:70])


def chay_tung_anh(sess, lo):
    return np.concatenate([sess.run(None, {"images": lo[i:i + 1]})[0] for i in range(len(lo))])


print(np.abs(chay_tung_anh(s_b1, lo8) - ref[:8]).max() < 1e-5)
[1, 3, 64, 64]
batch 8: [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Got invalid dimensions for 
True

Đầu vào cố định [1, 3, 64, 64]. Chạy từng ảnh chấp nhận được khi trạm chỉ có một camera, mỗi chu kỳ một ảnh (trường hợp phổ biến nhất). Khi có nhiều camera hoặc cắt nhiều ROI trong một ảnh, batch động giúp chạy một lần cho tất cả, nhanh hơn rõ rệt trên GPU. Trục cố định đôi khi lại được ưu tiên vì một số runtime (TensorRT, NPU) tối ưu tốt hơn khi biết trước shape.

Bài 2. Đồng nghiệp viết tiền xử lý phía C# và gửi bạn tổng từng kênh của tensor đầu vào cho anh_01_NG.png: [138.9, 780.43, 1819.64], trong khi bộ ảnh vàng ghi [374.83, 780.43, 1579.52]. Logit của họ cũng lệch. Viết code Python thử các giả thuyết (quên BGR→RGB, áp mean/std theo thứ tự ngược, quên mean/std, quên chia 255) để tìm lỗi.

Xem đáp án
a = cv2.imread("bo_anh_vang/anh_01_NG.png")
nho = cv2.resize(a, (KICH_THUOC, KICH_THUOC), interpolation=cv2.INTER_AREA)
rgb = cv2.cvtColor(nho, cv2.COLOR_BGR2RGB).astype(np.float32)
bgr = nho.astype(np.float32)
gia_thuyet = {
    "đúng": (rgb / 255 - MEAN) / STD,
    "quên BGR->RGB": (bgr / 255 - MEAN) / STD,
    "mean/std thứ tự ngược": (rgb / 255 - MEAN[::-1]) / STD[::-1],
    "quên mean/std": rgb / 255,
    "quên chia 255": (rgb - MEAN) / STD,
}
bao_cao = np.array([138.9, 780.43, 1819.64])
for ten, t in gia_thuyet.items():
    tong = t.transpose(2, 0, 1).sum(axis=(1, 2))
    print(f"{ten:22s} {np.array2string(tong, precision=2, suppress_small=True):34s}"
          f" lệch {np.abs(tong - bao_cao).max():.2f}")
đúng                   [ 374.83  780.43 1579.52]          lệch 240.12
quên BGR->RGB          [ 138.9   780.43 1819.64]          lệch 0.00
mean/std thứ tự ngược  [1819.64  780.43  138.9 ]          lệch 1680.74
quên mean/std          [2072.39 2042.59 2018.37]          lệch 1933.49
quên chia 255          [2.30e+06 2.32e+06 2.28e+06]       lệch 2316153.82

Chỉ giả thuyết "quên BGR→RGB" cho đúng ba con số. Kênh G (giữa) giống nhau ở mọi giả thuyết đảo kênh vì G nằm giữa trong cả BGR và RGB; kênh 0 và 2 bị tráo dữ liệu nhưng vẫn chuẩn hóa bằng hằng số R và B. Giả thuyết "mean/std thứ tự ngược" cho ba con số giống hệt nhưng theo thứ tự ngược lại, vì thế phải so từng kênh theo đúng thứ tự. Bài học: thẻ mô hình phải ghi rõ thứ tự kênh và mean/std theo thứ tự đó (ở đây là R, G, B).

Bài 3. Viết hàm kiem_chung(sess, model, ds_anh, atol=1e-5) trả về dict gồm sai khác logit lớn nhất, tỷ lệ khớp quyết định, số ảnh có P(NG) trong vùng 0,4–0,6, và "dat": True/False (đạt khi sai khác ≤ atol và khớp 100%). Chạy với sess và với một model "lỡ chạy train" (bản sao đã chạy forward ở chế độ train rồi eval lại).

Xem đáp án
def kiem_chung(sess, model, ds_anh, atol=1e-5):
    model.eval()
    vao = np.stack([tien_xu_ly(a) for a in ds_anh])
    with torch.no_grad():
        r = model(torch.from_numpy(vao)).numpy()
    o = sess.run(None, {"images": vao})[0]
    p = softmax(o)[:, 1]
    kq = {"sai_khac_max": float(np.abs(o - r).max()),
          "khop": float((o.argmax(1) == r.argmax(1)).mean()),
          "sat_nguong": int(((p > 0.4) & (p < 0.6)).sum())}
    kq["dat"] = kq["sai_khac_max"] <= atol and kq["khop"] == 1.0
    return kq


print(kiem_chung(sess, model, anh_kt[:50]))

hong = copy.deepcopy(model).train()
with torch.no_grad():
    hong(torch.from_numpy(dau_vao[:16]))         # một lần forward ở chế độ train là đủ làm lệch BN
print(kiem_chung(sess, hong, anh_kt[:50]))
{'sai_khac_max': 4.76837158203125e-07, 'khop': 1.0, 'sat_nguong': 0, 'dat': True}
{'sai_khac_max': 0.07642316818237305, 'khop': 1.0, 'sat_nguong': 0, 'dat': False}

sess vẫn dùng được dù file ok_ng.onnx trên đĩa đã bị ta cố tình làm hỏng ở mục 10: session đã nạp mô hình vào bộ nhớ từ trước. Trường hợp thứ hai: quyết định vẫn khớp 100% nhưng logit lệch gần 0,08 (gấp hàng trăm nghìn lần sai số làm tròn), đúng loại lỗi "âm thầm" mà chỉ nhìn tỷ lệ khớp sẽ bỏ qua. Hàm này nên chạy trong script xuất mô hình, và kết quả của nó ghi thẳng vào mục kiem_chung của thẻ mô hình.

Tóm tắt

  • model.eval() trước khi xuất và trước khi so sánh; chạy forward ở chế độ train làm hỏng thống kê BatchNorm ngay cả trong no_grad().
  • torch 2.14: torch.onnx.export mặc định dynamo=True (cần onnxscript), trục động khai báo bằng dynamic_shapes theo tên tham số forward, opset mặc định 20, external_data=True tạo thêm file .onnx.data.
  • onnx.checker chỉ kiểm tra cấu trúc; in get_inputs()/get_outputs() để biết "hợp đồng" của mô hình.
  • ONNX Runtime: tạo session một lần, đầu vào float32 NCHW; sai kiểu/shape thì báo lỗi, sai tiền xử lý thì không.
  • So sánh trên N ảnh: sai khác lớn nhất (FP32 CPU cỡ 10-6), assert_allclose, tỷ lệ khớp quyết định, số ảnh sát ngưỡng.
  • Tiền xử lý là nguồn sai lệch số một: một hàm dùng chung, đặc tả trong thẻ mô hình, bộ ảnh vàng PNG + logit mong đợi + tổng từng kênh.
  • YOLO: model.export(format="onnx"), in shape đầu ra trước khi giải mã, so khung bằng ghép cặp IoU.
  • Thẻ mô hình JSON (phiên bản, opset, đặc tả vào/ra, tiền xử lý, ngưỡng, SHA-256, kết quả kiểm chứng) đi cùng file ONNX; ứng dụng kiểm tra mã băm trước khi nạp.

Bài tiếp theo: Chạy mô hình ONNX trong C#, nơi bạn dùng chính bộ ảnh vàng này để kiểm tra phía C#.

Bài tiếp theo
Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.