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

Chạy mô hình ONNX trong C#: tiền xử lý, suy luận, giải mã đầu ra

Dùng ONNX Runtime và OpenCvSharp trong .NET: tiền xử lý khớp Python tới 1e-6, DenseTensor, giải mã phân loại và YOLO, parity test với bộ ảnh vàng.

Chạy mô hình ONNX trong C#: tiền xử lý, suy luận, giải mã đầu ra

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

  • Tiền xử lý đúng như lúc huấn luyện
  • Chạy suy luận trong .NET
  • Giải mã đầu ra

Nên học trước: Xuất ONNX và kiểm tra kết quả khớp PyTorch

Nội dung bài viết
  1. 1. Bức tranh tổng thể: Python huấn luyện, C# chạy trên trạm
  2. 2. Phía Python: mô hình nhỏ và bộ ảnh vàng
  3. 3. Tạo dự án C# và cài gói
  4. 4. InferenceSession: tạo một lần, đọc "hợp đồng" của mô hình
  5. 5. Tiền xử lý trong C#: giống hệt Python, từng bước
  6. 6. Chạy suy luận: DenseTensor, NamedOnnxValue, đo thời gian
  7. 7. Chương trình parity test với bộ ảnh vàng
  8. 8. SessionOptions và Execution Provider
  9. 9. Giải mã đầu ra phát hiện vật: letterbox, NMS, đổi tọa độ
  10. 10. API OrtValue (ONNX Runtime 1.16 trở lên)
  11. 11. Lỗi thường gặp
  12. 12. Bài tập
  13. Tóm tắt

Ở bài trước "Xuất ONNX và kiểm tra kết quả khớp PyTorch", bạn đã có một file .onnx được kiểm chứng trong Python. Nhưng phần mềm trạm kiểm tra ở nhiều nhà máy Việt Nam lại viết bằng C# (WinForms, WPF, hoặc dịch vụ .NET chạy cạnh SDK camera và thư viện PLC). Cầu nối là ONNX Runtime cho .NET: cùng lõi C++ với bản Python, gọi trực tiếp từ C#, không cần cài Python trên máy công nghiệp. Khó khăn thật sự không nằm ở dòng gọi mô hình, mà ở chỗ viết lại tiền xử lý cho giống hệt lúc huấn luyện và giải mã đầu ra cho đúng. Bài này đi trọn đường đó: chuẩn bị bộ ảnh vàng trong Python, viết ứng dụng console C# đọc ảnh bằng OpenCvSharp, chạy suy luận, đo thời gian, tự so với bộ ảnh vàng, rồi giải mã đầu ra phân loại lẫn phát hiện vật. Bài sau "Tăng tốc suy luận: OpenVINO, TensorRT, FP16, INT8" sẽ làm cho đúng chương trình này chạy nhanh hơn.

Mục tiêu cuối bàiCài đúng gói NuGet (CPU hay GPU); tạo InferenceSession một lần với SessionOptions phù hợp; tiền xử lý trong C# cho ra tensor trùng với Python tới sai số 10-6; chạy suy luận với DenseTensor<float> + NamedOnnxValue (và biết API OrtValue mới hơn); giải phóng tài nguyên đúng cách bằng using; giải mã đầu ra phân loại (softmax, ngưỡng) và phát hiện vật (letterbox, NMS); kiểm tra tương đương (parity test) với bộ ảnh vàng do Python tạo.

1. Bức tranh tổng thể: Python huấn luyện, C# chạy trên trạm

PC phát triển (Python)                          PC trạm (C#, .NET)
──────────────────────                          ─────────────────────────────
mô hình .pt ──export──► ok_ng_cs.onnx ─────────► InferenceSession (tạo 1 lần)
tien_xu_ly() (OpenCV-Python)                     TienXuLy.Chay() (OpenCvSharp)  ← phải giống hệt
golden/  (ảnh PNG + golden.json) ──────────────► chương trình parity test: so logit từng ảnh
                                                 camera ─► tiền xử lý ─► Run ─► giải mã ─► OK/NG ─► PLC

Điểm mấu chốt: file ONNX chỉ chứa mạng. Mọi bước trước mạng (resize, đổi kênh màu, chia 255, trừ mean chia std, đổi layout) và sau mạng (softmax, ngưỡng, NMS, đổi tọa độ) nằm trong code của bạn, viết hai lần bằng hai ngôn ngữ. Vì vậy cách làm chuyên nghiệp là: Python xuất kèm một bộ ảnh vàng (golden set) có đầu ra mong đợi, và chương trình C# phải tái tạo đúng các con số đó trước khi được lắp lên dây chuyền.

Chọn gói NuGet

Gói NuGetChạy trênGhi chú
Microsoft.ML.OnnxRuntimeCPU (Windows, Linux, macOS)Lựa chọn mặc định, đủ cho đa số mô hình phân loại nhỏ trên PC công nghiệp
Microsoft.ML.OnnxRuntime.GpuGPU NVIDIA qua CUDA (và TensorRT EP)Cần driver, CUDA và cuDNN đúng phiên bản mà bản ONNX Runtime đó yêu cầu (xem bảng tương thích trong tài liệu ONNX Runtime); vẫn chạy được CPU
Microsoft.ML.OnnxRuntime.DirectMLWindows, mọi GPU hỗ trợ DirectX 12 (NVIDIA, AMD, Intel)Tiện khi không muốn phụ thuộc CUDA; tốc độ thường kém CUDA/TensorRT trên cùng GPU NVIDIA
Intel.ML.OnnxRuntime.OpenVinoCPU, GPU tích hợp, NPU của IntelGói do Intel phát hành, dùng OpenVINO Execution Provider
Chỉ tham chiếu MỘT gói ONNX RuntimeCác gói trên đều chứa thư viện gốc tên onnxruntime.dll/libonnxruntime.so. Cài cùng lúc Microsoft.ML.OnnxRuntime và Microsoft.ML.OnnxRuntime.Gpu làm hai bản dll đè nhau khi build, và lỗi chỉ lộ ra lúc chạy (không tìm thấy CUDA provider, hoặc sai phiên bản). Muốn chạy GPU thì thay gói CPU bằng gói GPU, không cài thêm.

2. Phía Python: mô hình nhỏ và bộ ảnh vàng

Để bài tự chạy được, ta dựng lại tình huống của bài trước ở cỡ nhỏ: ảnh camera 120×120 pixel của tấm kim loại; ảnh OK có thể có chấm mực xanh của QC, ảnh NG có vết gỉ màu cam. Mô hình chỉ khác nhau ở màu, nên sai thứ tự kênh màu sẽ lộ ra ngay. Với mô hình thật của bạn, chỉ cần thay mô hình và giữ nguyên hàm tien_xu_ly đã dùng lúc huấn luyện.

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.
import hashlib
import json
import shutil
from pathlib import Path

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

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

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


def tao_anh(ng, rng):
    """Ảnh 120x120 BGR: tấm kim loại xám; OK có thể có chấm mực xanh, NG có vết gỉ cam."""
    anh = np.full((120, 120, 3), 128, np.float32) + rng.normal(0, 8, (120, 120, 3))
    anh += np.linspace(-15, 15, 120)[None, :, None]             # ánh sáng không đều
    tam = tuple(int(v) for v in rng.integers(25, 95, 2))
    r = int(rng.integers(6, 12))
    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)


def tien_xu_ly(anh_bgr):
    """BGR uint8 (H, W, 3) -> float32 (3, 64, 64): resize INTER_AREA, BGR->RGB, /255, mean/std, HWC->CHW."""
    a = cv2.resize(anh_bgr, (KICH_THUOC, KICH_THUOC), interpolation=cv2.INTER_AREA)
    a = cv2.cvtColor(a, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    a = (a - MEAN) / STD
    return np.ascontiguousarray(a.transpose(2, 0, 1))


rng = np.random.default_rng(0)
nhan = np.array([i % 2 for i in range(400)])                     # 0 = OK, 1 = NG
X = torch.from_numpy(np.stack([tien_xu_ly(tao_anh(n, rng)) for n in nhan]))
y = torch.from_numpy(nhan).long()

model = nn.Sequential(
    nn.Conv2d(3, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(16, 16, 3, padding=1), nn.ReLU(),
    nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(16, 2))
opt = torch.optim.Adam(model.parameters(), lr=5e-3)
for ep in range(4):
    for i in range(0, 400, 32):
        loss = nn.functional.cross_entropy(model(X[i:i + 32]), y[i:i + 32])
        opt.zero_grad()
        loss.backward()
        opt.step()
model.eval()
with torch.no_grad():
    print("độ chính xác trên tập huấn luyện:", (model(X).argmax(1) == y).float().mean().item())

torch.onnx.export(model, (X[:1],), "ok_ng_cs.onnx", input_names=["images"], output_names=["logits"],
                  dynamic_shapes={"input": {0: "batch"}},       # "input" = tên tham số forward của nn.Sequential
                  opset_version=18, external_data=False, verbose=False)
print("ok_ng_cs.onnx:", Path("ok_ng_cs.onnx").stat().st_size, "byte")
độ chính xác trên tập huấn luyện: 1.0
ok_ng_cs.onnx: 29948 byte

Độ chính xác trên dữ liệu giả này dễ đạt 100%; con số có thể khác chút ít trên máy khác. Điều quan trọng là file ok_ng_cs.onnx có đầu vào tên images, shape [batch, 3, 64, 64], đầu ra logits shape [batch, 2] (lớp 0 = OK, lớp 1 = NG).

Bây giờ tạo bộ ảnh vàng: 6 ảnh PNG (PNG không nén mất dữ liệu, nên hai phía đọc ra cùng pixel), kèm file golden.json ghi đặc tả tiền xử lý, mã băm mô hình và với mỗi ảnh: tổng từng kênh của tensor, giá trị pixel góc trên trái của 3 kênh, logit, xác suất NG và kết luận. Chép nguyên thư mục golden/ sang máy có chương trình C#.

import onnxruntime as ort

sess = ort.InferenceSession("ok_ng_cs.onnx", providers=["CPUExecutionProvider"])
vang = Path("golden")
vang.mkdir(exist_ok=True)
shutil.copy("ok_ng_cs.onnx", vang / "ok_ng_cs.onnx")

rng_v = np.random.default_rng(7)
ds = []
for i in range(6):
    ng = i % 2
    anh = tao_anh(ng, rng_v)
    ten = f"anh_{i:02d}_{'NG' if ng else 'OK'}.png"
    cv2.imwrite(str(vang / ten), anh)
    t = tien_xu_ly(anh)
    lg = sess.run(["logits"], {"images": t[None]})[0][0]
    p_ng = float(1.0 / (1.0 + np.exp(lg[0] - lg[1])))           # softmax 2 lớp
    ds.append({"file": ten,
               "tong_kenh": [round(float(v), 3) for v in t.sum(axis=(1, 2))],
               "goc_tren_trai": [round(float(t[c, 0, 0]), 5) for c in range(3)],
               "logits": [round(float(v), 5) for v in lg],
               "p_ng": round(p_ng, 5),
               "ket_qua": "NG" if p_ng >= 0.5 else "OK"})

the = {"model": "ok_ng_cs.onnx",
       "sha256": hashlib.sha256(Path("ok_ng_cs.onnx").read_bytes()).hexdigest(),
       "input": {"name": "images", "shape": [1, 3, 64, 64], "layout": "NCHW", "dtype": "float32"},
       "output": {"name": "logits", "classes": ["OK", "NG"]},
       "preprocess": {"resize": [64, 64], "interpolation": "INTER_AREA", "color": "BGR->RGB",
                      "scale": "1/255", "mean_rgb": [0.485, 0.456, 0.406], "std_rgb": [0.229, 0.224, 0.225]},
       "threshold_ng": 0.5, "tol_logit": 1e-4, "images": ds}
(vang / "golden.json").write_text(json.dumps(the, indent=1, ensure_ascii=False), encoding="utf-8")

print("sha256:", the["sha256"][:16], "...")
for d in ds:
    print(f"{d['file']}  logits={d['logits']}  p_ng={d['p_ng']:.5f}  {d['ket_qua']}  tong_kenh={d['tong_kenh']}")
sha256: c22e3d19b75e8237 ...
anh_00_OK.png  logits=[9.98768, -3.35116]  p_ng=0.00000  OK  tong_kenh=[220.793, 796.501, 1747.976]
anh_01_NG.png  logits=[-19.31605, 24.66094]  p_ng=1.00000  NG  tong_kenh=[391.201, 784.036, 1576.578]
anh_02_OK.png  logits=[10.46421, -3.50261]  p_ng=0.00000  OK  tong_kenh=[182.964, 801.438, 1783.828]
anh_03_NG.png  logits=[-15.22198, 20.55314]  p_ng=1.00000  NG  tong_kenh=[349.622, 773.094, 1594.948]
anh_04_OK.png  logits=[9.03428, -3.04875]  p_ng=0.00001  OK  tong_kenh=[268.194, 809.369, 1714.948]
anh_05_NG.png  logits=[-23.4325, 28.80084]  p_ng=1.00000  NG  tong_kenh=[410.432, 776.771, 1556.883]

Đây là "đề thi" cho chương trình C#: với từng ảnh, logit C# tính ra phải lệch không quá tol_logit = 1e-4 so với cột logits. Mã băm của bạn sẽ khác nếu trọng số khác; điều quan trọng là phía C# kiểm tra đúng mã băm ghi trong file JSON đi kèm.

3. Tạo dự án C# và cài gói

dotnet new console -n OkNgOnnx -f net8.0          # .NET 8 (LTS) trở lên
cd OkNgOnnx
dotnet add package Microsoft.ML.OnnxRuntime       # CPU. GPU NVIDIA: thay bằng Microsoft.ML.OnnxRuntime.Gpu
dotnet add package OpenCvSharp4                   # API OpenCV cho .NET
dotnet add package OpenCvSharp4.runtime.win       # thư viện gốc OpenCV cho Windows x64
# Trên Linux: chọn gói runtime OpenCvSharp phù hợp bản phân phối (tìm "OpenCvSharp4 runtime linux" trên NuGet).
# Chép thư mục golden/ từ Python vào thư mục dự án.
dotnet run -c Release -- golden

Để thư mục golden/ được chép sang thư mục build, thêm vào file OkNgOnnx.csproj:

<ItemGroup>
  <None Update="golden\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Luôn đo và nghiệm thu bằng bản Release. Bản Debug chạy chậm hơn rõ rệt ở phần vòng lặp C# (tiền xử lý, giải mã), dù phần suy luận do thư viện gốc đảm nhiệm.

4. InferenceSession: tạo một lần, đọc "hợp đồng" của mô hình

InferenceSession nạp file ONNX, tối ưu đồ thị và cấp phát bộ nhớ. Việc này tốn từ vài chục mili giây tới vài giây tùy mô hình, nên chỉ làm một lần khi ứng dụng khởi động, rồi dùng lại cho mọi ảnh. InferenceSession, SessionOptions và kết quả của Run đều giữ bộ nhớ gốc (native) ngoài tầm của bộ gom rác .NET, nên đều cài IDisposable: dùng using để giải phóng đúng lúc.

using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;

using var options = new SessionOptions
{
    GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,   // gộp phép toán, bỏ nút thừa
    IntraOpNumThreads = 4,      // số luồng cho MỘT phép toán (Conv, MatMul...)
    InterOpNumThreads = 1,      // số luồng chạy song song các nhánh đồ thị (chỉ dùng khi ORT_PARALLEL)
    ExecutionMode = ExecutionMode.ORT_SEQUENTIAL
};
using var session = new InferenceSession("golden/ok_ng_cs.onnx", options);

foreach (var (name, meta) in session.InputMetadata)
    Console.WriteLine($"input  {name}: {meta.ElementType.Name} [{string.Join(", ", meta.Dimensions)}]");
foreach (var (name, meta) in session.OutputMetadata)
    Console.WriteLine($"output {name}: {meta.ElementType.Name} [{string.Join(", ", meta.Dimensions)}]");

Với file của mục 2, chương trình in đầu vào images kiểu Single (tên .NET của float) shape [-1, 3, 64, 64] và đầu ra logits shape [-1, 2]: trục động (batch) hiện là -1 trong Dimensions; tên trục (batch) nằm trong SymbolicDimensions. Hãy in metadata này mỗi khi nhận mô hình mới, và so với thẻ mô hình trước khi viết code giải mã.

Một session, nhiều luồngGọi session.Run đồng thời từ nhiều luồng là an toàn (ONNX Runtime thiết kế như vậy), nên một trạm hai camera có thể dùng chung một session. Ngược lại, đừng tạo session mới cho mỗi ảnh: vừa chậm, vừa dễ rò bộ nhớ gốc nếu quên Dispose.

5. Tiền xử lý trong C#: giống hệt Python, từng bước

Hàm tien_xu_ly của Python có 5 bước. Bảng dưới là bản dịch từng bước sang OpenCvSharp, kèm cái bẫy của mỗi bước:

BướcPython (lúc huấn luyện)C# (OpenCvSharp)Bẫy hay gặp
1. Resizecv2.resize(..., INTER_AREA)Cv2.Resize(..., InterpolationFlags.Area)Mặc định của Cv2.Resize là Linear: sai khác nhỏ nhưng khác 0
2. Đổi kênhCOLOR_BGR2RGBColorConversionCodes.BGR2RGBOpenCvSharp đọc ảnh BGR giống OpenCV; quên đổi là lỗi số một
3. Chia 255/ 255.0gộp vào ConvertTo(alpha, beta)Chia số nguyên px / 255 trong C# cho 0 hoặc 1
4. Mean/std(a - MEAN) / STD theo R, G, Balpha = 1/(255·std[c]), beta = -mean[c]/std[c]Áp mean/std theo thứ tự B, G, R
5. HWC→CHWtranspose(2, 0, 1)Cv2.Split rồi chép từng kênh liền nhauChép thẳng dữ liệu Mat (đang HWC xen kẽ) vào tensor NCHW

Bước 3 và 4 gộp được thành một phép biến đổi tuyến tính cho mỗi kênh: (x/255 − mean)/std = x · 1/(255·std) + (−mean/std). OpenCV làm đúng phép này trong ConvertTo(dst, kiểu, alpha, beta), chạy bằng mã gốc tối ưu thay vì vòng lặp C# trên từng pixel:

using System.Runtime.InteropServices;
using OpenCvSharp;

/// <summary>Tiền xử lý giống hệt tien_xu_ly() của Python. Ghi kết quả CHW (R, G, B) vào buffer.</summary>
sealed class TienXuLy
{
    private readonly int _size;
    private readonly float[] _meanRgb, _stdRgb;

    public TienXuLy(int size, float[] meanRgb, float[] stdRgb)
    {
        _size = size; _meanRgb = meanRgb; _stdRgb = stdRgb;
    }

    public int SoPhanTu => 3 * _size * _size;

    public void Chay(Mat bgr, float[] buffer, int offset = 0)
    {
        if (bgr.Empty() || bgr.Type() != MatType.CV_8UC3)
            throw new ArgumentException("Cần ảnh BGR 8 bit 3 kênh");

        using var nho = new Mat();
        Cv2.Resize(bgr, nho, new Size(_size, _size), 0, 0, InterpolationFlags.Area);   // bước 1
        using var rgb = new Mat();
        Cv2.CvtColor(nho, rgb, ColorConversionCodes.BGR2RGB);                          // bước 2

        Mat[] kenh = Cv2.Split(rgb);            // 3 Mat CV_8UC1 theo thứ tự R, G, B
        try
        {
            int n = _size * _size;
            using var f = new Mat();
            for (int c = 0; c < 3; c++)
            {
                double alpha = 1.0 / (255.0 * _stdRgb[c]);                             // bước 3 + 4
                double beta = -_meanRgb[c] / _stdRgb[c];
                kenh[c].ConvertTo(f, MatType.CV_32FC1, alpha, beta);
                Marshal.Copy(f.Data, buffer, offset + c * n, n);                       // bước 5: kênh c liền một khối
            }
        }
        finally
        {
            foreach (var k in kenh) k.Dispose();
        }
    }
}

Vài chi tiết C# đáng chú ý: mọi Mat trung gian đều nằm trong using (mỗi Mat giữ bộ nhớ gốc; quên Dispose trong vòng lặp 30 ảnh/giây là bộ nhớ tăng dần tới khi ứng dụng sập sau vài giờ); Marshal.Copy chép thẳng từ con trỏ dữ liệu của Mat, an toàn vì Mat mới tạo bởi ConvertTo luôn liên tục trong bộ nhớ; tham số offset cho phép ghi nhiều ảnh nối tiếp vào một buffer khi chạy batch.

Đừng tin bảng trên chỉ vì nó "nhìn đúng". Đoạn Python sau mô phỏng y hệt các phép của lớp C# (tách kênh, alpha/beta tính bằng số thực 64 bit như ConvertTo, chép từng kênh liền khối) và so với tien_xu_ly:

def tien_xu_ly_kieu_csharp(anh_bgr):
    """Mô phỏng đúng từng bước của lớp TienXuLy (C#), trả về buffer phẳng như float[] bên C#."""
    nho = cv2.resize(anh_bgr, (KICH_THUOC, KICH_THUOC), interpolation=cv2.INTER_AREA)
    rgb = cv2.cvtColor(nho, cv2.COLOR_BGR2RGB)
    n = KICH_THUOC * KICH_THUOC
    buffer = np.empty(3 * n, np.float32)
    for c, kenh in enumerate(cv2.split(rgb)):
        alpha = 1.0 / (255.0 * float(STD[c]))
        beta = -float(MEAN[c]) / float(STD[c])
        f = (kenh.astype(np.float64) * alpha + beta).astype(np.float32)   # ConvertTo: tính double, lưu float
        buffer[c * n:(c + 1) * n] = f.ravel()                            # Marshal.Copy từng kênh
    return buffer


sai_tensor, sai_logit = 0.0, 0.0
for d in ds:
    anh = cv2.imread(str(vang / d["file"]))
    buf = tien_xu_ly_kieu_csharp(anh)
    chuan = tien_xu_ly(anh)
    sai_tensor = max(sai_tensor, float(np.abs(buf - chuan.ravel()).max()))
    lg = sess.run(None, {"images": buf.reshape(1, 3, KICH_THUOC, KICH_THUOC)})[0][0]
    sai_logit = max(sai_logit, float(np.abs(lg - np.array(d["logits"])).max()))
print(f"sai khác tensor lớn nhất: {sai_tensor:.2e}")
print(f"sai khác logit so với golden.json: {sai_logit:.2e}  (tol_logit = {the['tol_logit']})")
sai khác tensor lớn nhất: 1.49e-07
sai khác logit so với golden.json: 6.15e-06  (tol_logit = 0.0001)

Sai khác cỡ 10-7 chỉ là làm tròn float32 (hai cách tính cho kết quả khác ở chữ số cuối), còn sai khác logit chủ yếu do golden.json làm tròn 5 chữ số. Vậy công thức gộp alpha/beta là đúng; nếu chương trình C# của bạn vẫn lệch, lỗi nằm ở chỗ khác (thứ tự kênh, kiểu nội suy, đọc sai ảnh).

6. Chạy suy luận: DenseTensor, NamedOnnxValue, đo thời gian

Đường gọi lâu đời và phổ biến nhất của ONNX Runtime C#: bọc mảng float[] thành DenseTensor<float> có shape NCHW, đặt tên bằng NamedOnnxValue.CreateFromTensor, gọi session.Run. DenseTensor tạo từ một mảng sẽ dùng chung bộ nhớ với mảng đó (không chép), nên ta cấp phát buffer một lần, mỗi ảnh chỉ ghi đè dữ liệu mới vào.

using System.Diagnostics;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;

const int S = 64;
var pre = new TienXuLy(S, new[] { 0.485f, 0.456f, 0.406f }, new[] { 0.229f, 0.224f, 0.225f });
var buffer = new float[pre.SoPhanTu];
var tensor = new DenseTensor<float>(buffer, new[] { 1, 3, S, S });       // bọc buffer, không chép
var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", tensor) };
using var session = new InferenceSession("golden/ok_ng_cs.onnx");     // tạo một lần

(float pNg, float[] logits) PhanLoai(Mat bgr)
{
    pre.Chay(bgr, buffer);                                   // ghi đè buffer => tensor có dữ liệu mới
    using var results = session.Run(inputs);                 // IDisposableReadOnlyCollection: phải Dispose
    float[] lg = results.First().AsEnumerable<float>().ToArray();   // [logit_OK, logit_NG]
    float p = 1f / (1f + MathF.Exp(lg[0] - lg[1]));          // softmax 2 lớp = sigmoid(lg1 - lg0)
    return (p, lg);
}

using var img = Cv2.ImRead("golden/anh_01_NG.png", ImreadModes.Color);
for (int i = 0; i < 10; i++) PhanLoai(img);                 // khởi động nóng: lần đầu luôn chậm

var sw = new Stopwatch();
var ms = new List<double>();
for (int i = 0; i < 200; i++)
{
    sw.Restart();
    var (p, _) = PhanLoai(img);
    sw.Stop();
    ms.Add(sw.Elapsed.TotalMilliseconds);
}
ms.Sort();
var (pNg, logits) = PhanLoai(img);
Console.WriteLine($"logits = [{logits[0]:F5}, {logits[1]:F5}]  P(NG) = {pNg:F5}  => {(pNg >= 0.5f ? "NG" : "OK")}");
Console.WriteLine($"tiền xử lý + suy luận: p50 {ms[ms.Count / 2]:F3} ms, p99 {ms[(int)(ms.Count * 0.99)]:F3} ms");

Kết quả phân loại của ảnh này phải trùng dòng anh_01_NG.png trong bảng ở mục 2 tới 4–5 chữ số. Thời gian thì tùy máy; với mô hình 64×64 cỡ này, tiền xử lý cộng suy luận thường dưới 1 ms trên CPU máy tính để bàn.

Những điểm dễ sai trong đoạn trên:

  • Tên đầu vào phải khớp chính xác "images". Nếu không chắc, dùng session.InputMetadata.Keys.First(), nhưng tốt hơn là kiểm tra tên với thẻ mô hình và báo lỗi rõ ràng khi khác.
  • Thứ tự đầu ra: results trả theo thứ tự đầu ra của mô hình; với mô hình nhiều đầu ra, lấy theo tên (results.First(r => r.Name == "logits")).
  • Kết quả Run phải Dispose: các giá trị đầu ra giữ bộ nhớ gốc. Chép số cần dùng ra mảng .NET (ToArray()) trước khi khối using kết thúc.
  • Đo thời gian bằng Stopwatch, sau vài lần chạy khởi động nóng, báo p50 và p99 chứ không chỉ trung bình. Bài sau đi sâu vào cách đo.

7. Chương trình parity test với bộ ảnh vàng

Đây là chương trình bạn chạy mỗi khi đổi mô hình, đổi phiên bản ONNX Runtime/OpenCvSharp, hoặc sửa code tiền xử lý. Nó đọc golden.json, kiểm tra mã băm mô hình, chạy từng ảnh, so logit với dung sai, và khi lệch thì in thêm tổng từng kênh để khoanh vùng lỗi (tiền xử lý hay mô hình). Mã thoát khác 0 khi không đạt, để dùng được trong script build tự động.

// Program.cs (lớp TienXuLy của mục 5 đặt trong file TienXuLy.cs): dotnet run -c Release -- golden
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;

string dir = args.Length > 0 ? args[0] : "golden";
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "golden.json")));
JsonElement root = doc.RootElement;

// 1) Đúng file mô hình?
string modelPath = Path.Combine(dir, root.GetProperty("model").GetString()!);
string sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(modelPath))).ToLowerInvariant();
if (sha != root.GetProperty("sha256").GetString())
{
    Console.WriteLine($"SAI MÔ HÌNH: sha256 {sha[..16]}... khác golden.json");
    return 2;
}

// 2) Tiền xử lý theo đặc tả trong JSON, không gõ tay hằng số
JsonElement pp = root.GetProperty("preprocess");
float[] mean = pp.GetProperty("mean_rgb").EnumerateArray().Select(e => e.GetSingle()).ToArray();
float[] std = pp.GetProperty("std_rgb").EnumerateArray().Select(e => e.GetSingle()).ToArray();
int size = pp.GetProperty("resize")[0].GetInt32();
var pre = new TienXuLy(size, mean, std);

string inName = root.GetProperty("input").GetProperty("name").GetString()!;
float tol = root.GetProperty("tol_logit").GetSingle();
using var session = new InferenceSession(modelPath);
var buffer = new float[pre.SoPhanTu];
var inputs = new List<NamedOnnxValue>
{
    NamedOnnxValue.CreateFromTensor(inName, new DenseTensor<float>(buffer, new[] { 1, 3, size, size }))
};

// 3) Chạy từng ảnh và so
int loi = 0;
foreach (JsonElement item in root.GetProperty("images").EnumerateArray())
{
    string file = item.GetProperty("file").GetString()!;
    using Mat img = Cv2.ImRead(Path.Combine(dir, file), ImreadModes.Color);
    if (img.Empty()) { Console.WriteLine($"{file}: không đọc được ảnh"); loi++; continue; }

    pre.Chay(img, buffer);
    float[] lg;
    using (var results = session.Run(inputs))
        lg = results.First().AsEnumerable<float>().ToArray();

    float[] mong = item.GetProperty("logits").EnumerateArray().Select(e => e.GetSingle()).ToArray();
    float lech = lg.Zip(mong, (a, b) => MathF.Abs(a - b)).Max();
    bool dat = lech <= tol;
    Console.WriteLine($"{file}: lệch logit {lech:E2} {(dat ? "ĐẠT" : "KHÔNG ĐẠT")}");
    if (!dat)
    {
        loi++;
        int n = size * size;                       // in tổng từng kênh để so với "tong_kenh"
        var tong = Enumerable.Range(0, 3).Select(c => buffer.Skip(c * n).Take(n).Sum());
        Console.WriteLine($"   tổng kênh C#: [{string.Join(", ", tong.Select(v => v.ToString("F3")))}]"
                        + $"  golden: {item.GetProperty("tong_kenh")}");
    }
}
Console.WriteLine(loi == 0 ? "PARITY: ĐẠT" : $"PARITY: {loi} ảnh lệch");
return loi == 0 ? 0 : 1;

Cách đọc kết quả khi không đạt (đúng mẹo "tổng từng kênh" của bài trước):

Triệu chứngNguyên nhân khả dĩ
Tổng kênh 0 và 2 tráo nhau gần đúng, kênh 1 khớpQuên BGR→RGB, hoặc mean/std áp theo thứ tự B, G, R
Tổng các kênh lệch rất ít (phần trăm nhỏ), logit lệch 10-3–10-1Sai kiểu nội suy khi resize (Linear thay Area), hoặc resize hai lần
Tổng kênh lệch hàng nghìn lầnQuên chia 255 hoặc quên mean/std
Tổng kênh khớp mà logit lệchSai file mô hình, sai tên đầu vào/đầu ra, đọc nhầm đầu ra, hoặc buffer chưa được ghi trước khi Run

8. SessionOptions và Execution Provider

Execution Provider (EP) là "phần phụ trợ phần cứng" mà ONNX Runtime giao việc tính toán: CPU, CUDA, TensorRT, DirectML, OpenVINO… Bạn đăng ký EP trong SessionOptions theo thứ tự ưu tiên; phép toán nào EP không hỗ trợ sẽ rơi về CPU.

using Microsoft.ML.OnnxRuntime;

static InferenceSession TaoSession(string modelPath, string thietBi)
{
    using var opt = new SessionOptions
    {
        GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
        IntraOpNumThreads = Math.Max(1, Environment.ProcessorCount / 2),   // chừa CPU cho camera, PLC, giao diện
        LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING
    };
    switch (thietBi)
    {
        case "cuda": opt.AppendExecutionProvider_CUDA(0); break;    // cần gói Microsoft.ML.OnnxRuntime.Gpu
        case "dml":  opt.AppendExecutionProvider_DML(0);  break;    // cần gói Microsoft.ML.OnnxRuntime.DirectML
        default: break;                                             // chỉ CPU
    }
    return new InferenceSession(modelPath, opt);   // các tùy chọn đã được chép vào session
}
Tùy chọnÝ nghĩaGợi ý cho trạm vision
GraphOptimizationLevelMức tối ưu đồ thị: tắt, cơ bản, mở rộng, tất cảORT_ENABLE_ALL (mặc định đã là mức cao nhất)
IntraOpNumThreadsSố luồng song song bên trong một phép toánĐo với 1, 2, 4… luồng; đừng chiếm hết lõi CPU nếu cùng máy còn chạy SDK camera
InterOpNumThreads, ExecutionModeChạy song song các nhánh độc lập của đồ thịCNN phân loại gần như tuần tự: để ORT_SEQUENTIAL
AppendExecutionProvider_*Đăng ký EP phần cứngGọi trong khối try/catch; ghi log EP thực sự được dùng lúc khởi động
EP GPU lỗi thì sao?Nếu thiếu CUDA/cuDNN đúng phiên bản, AppendExecutionProvider_CUDA ném exception. Đừng nuốt lỗi rồi im lặng chạy CPU: thời gian xử lý tăng gấp nhiều lần và PLC bắt đầu báo timeout mà không ai hiểu vì sao. Hoặc dừng hẳn với thông báo rõ ràng, hoặc chạy CPU nhưng ghi cảnh báo lên log và màn hình.

9. Giải mã đầu ra phát hiện vật: letterbox, NMS, đổi tọa độ

Mô hình phát hiện vật (YOLO và tương tự) cần hai việc mà phân loại không có. Letterbox: thu nhỏ ảnh giữ nguyên tỉ lệ rồi đệm viền cho đủ hình vuông (ví dụ 640×640), để vật không bị méo. Sau suy luận phải làm ngược lại: trừ phần đệm, chia tỉ lệ, để tọa độ khung về ảnh gốc. NMS (Non-Maximum Suppression, loại bỏ khung trùng): mô hình đề xuất nhiều khung chồng lên cùng một vật, chỉ giữ khung điểm cao nhất trong mỗi nhóm có IoU lớn.

Định dạng đầu ra khác nhau giữa các đời mô hình, nên luôn in shape trước (mục 4). Hai dạng hay gặp:

DạngShape ví dụNội dungCần làm
Thô (YOLOv8, YOLO11 xuất mặc định)[1, 4 + số lớp, số đề xuất], ví dụ [1, 84, 8400]Mỗi cột: cx, cy, w, h (tọa độ trong ảnh letterbox), rồi điểm từng lớpLọc điểm, đổi cx-cy-w-h sang x1-y1-x2-y2, NMS, bỏ letterbox
End-to-end (YOLO26, YOLOv10)[1, số khung tối đa, 6], ví dụ [1, 300, 6]Mỗi hàng: x1, y1, x2, y2, điểm, lớpLọc điểm, bỏ letterbox (không cần NMS)

Viết giải mã bằng Python trước (dễ kiểm tra), rồi dịch sang C#. Đoạn sau dựng một đầu ra "giả" có đáp án biết trước cho ảnh camera 1280×960: vật A (lớp 0) ở (300, 200)–(500, 400) với hai đề xuất trùng nhau, vật B (lớp 1) ở (800, 500)–(1000, 560), và một đề xuất điểm thấp phải bị loại.

import numpy as np


def letterbox_thong_so(w, h, dich=640):
    r = min(dich / w, dich / h)
    nw, nh = round(w * r), round(h * r)
    return r, (dich - nw) // 2, (dich - nh) // 2                 # tỉ lệ, đệm trái, đệm trên


def iou(a, b):
    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
    return giao / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - giao)


def giai_ma_tho(out, r, pad_x, pad_y, conf=0.25, iou_max=0.45):
    """out: (1, 4 + nc, N). Trả về [(x1, y1, x2, y2, diem, lop)] theo tọa độ ảnh gốc."""
    p = out[0]
    diem_lop = p[4:]                                               # (nc, N)
    lop = diem_lop.argmax(0)
    diem = diem_lop.max(0)
    ung_vien = []
    for i in np.flatnonzero(diem >= conf):
        cx, cy, w, h = p[:4, i]
        ung_vien.append([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2, float(diem[i]), int(lop[i])])
    ung_vien.sort(key=lambda k: -k[4])
    giu = []
    for k in ung_vien:                                             # NMS theo từng lớp
        if all(k[5] != g[5] or iou(k, g) < iou_max for g in giu):
            giu.append(k)
    print(f"đề xuất {p.shape[1]}, qua ngưỡng điểm {len(ung_vien)}, sau NMS {len(giu)}")
    return [((x1 - pad_x) / r, (y1 - pad_y) / r, (x2 - pad_x) / r, (y2 - pad_y) / r, d, c)
            for x1, y1, x2, y2, d, c in giu]


r, pad_x, pad_y = letterbox_thong_so(1280, 960)
print(f"r = {r}, đệm trái = {pad_x}, đệm trên = {pad_y}")

rng = np.random.default_rng(0)
out = np.zeros((1, 6, 100), np.float32)
out[0, :4] = rng.uniform(50, 600, (4, 100))
out[0, 4:] = rng.uniform(0, 0.1, (2, 100))                         # nền: điểm thấp
for j, (x1, y1, x2, y2, lop, d) in enumerate([(300, 200, 500, 400, 0, 0.91), (306, 204, 504, 402, 0, 0.85),
                                               (800, 500, 1000, 560, 1, 0.77), (100, 700, 160, 760, 0, 0.20)]):
    a = np.array([x1, y1, x2, y2]) * r + [pad_x, pad_y, pad_x, pad_y]   # ảnh gốc -> ảnh letterbox
    out[0, :4, j] = [(a[0] + a[2]) / 2, (a[1] + a[3]) / 2, a[2] - a[0], a[3] - a[1]]
    out[0, 4 + lop, j] = d

for k in giai_ma_tho(out, r, pad_x, pad_y):
    print("lớp", k[5], "điểm", round(k[4], 2), "khung", [round(float(v), 1) for v in k[:4]])

# Dạng end-to-end (1, 300, 6): x1, y1, x2, y2, điểm, lớp trong ảnh letterbox; không cần NMS
e2e = np.zeros((1, 300, 6), np.float32)
e2e[0, 0] = [150, 180, 250, 280, 0.91, 0]
e2e[0, 1] = [400, 330, 500, 360, 0.77, 1]
for x1, y1, x2, y2, d, c in e2e[0][e2e[0, :, 4] >= 0.25]:
    print("e2e lớp", int(c), "khung", [round(float(v), 1) for v in
                                     ((x1 - pad_x) / r, (y1 - pad_y) / r, (x2 - pad_x) / r, (y2 - pad_y) / r)])
r = 0.5, đệm trái = 0, đệm trên = 80
đề xuất 100, qua ngưỡng điểm 3, sau NMS 2
lớp 0 điểm 0.91 khung [300.0, 200.0, 500.0, 400.0]
lớp 1 điểm 0.77 khung [800.0, 500.0, 1000.0, 560.0]
e2e lớp 0 khung [300.0, 200.0, 500.0, 400.0]
e2e lớp 1 khung [800.0, 500.0, 1000.0, 560.0]

Hai khung đúng vị trí ban đầu (sai khác dưới 0,1 pixel do làm tròn float32), khung trùng của vật A bị NMS loại, khung điểm 0,20 bị ngưỡng loại. Bản C# tương đương, làm việc trực tiếp trên mảng phẳng (truy cập Tensor theo chỉ số nhiều chiều chậm hơn nhiều với 8400 cột):

record struct Khung(float X1, float Y1, float X2, float Y2, float Diem, int Lop);

static float IoU(Khung a, Khung b)
{
    float w = MathF.Max(0, MathF.Min(a.X2, b.X2) - MathF.Max(a.X1, b.X1));
    float h = MathF.Max(0, MathF.Min(a.Y2, b.Y2) - MathF.Max(a.Y1, b.Y1));
    float giao = w * h;
    return giao / ((a.X2 - a.X1) * (a.Y2 - a.Y1) + (b.X2 - b.X1) * (b.Y2 - b.Y1) - giao);
}

/// out: mảng phẳng của tensor [1, 4 + nc, n]; phần tử (kênh k, đề xuất i) nằm ở out[k * n + i]
static List<Khung> GiaiMaTho(float[] o, int nc, int n, float r, int padX, int padY,
                             float conf = 0.25f, float iouMax = 0.45f)
{
    var ungVien = new List<Khung>();
    for (int i = 0; i < n; i++)
    {
        int lop = 0; float diem = o[4 * n + i];
        for (int c = 1; c < nc; c++)
            if (o[(4 + c) * n + i] > diem) { diem = o[(4 + c) * n + i]; lop = c; }
        if (diem < conf) continue;
        float cx = o[i], cy = o[n + i], w = o[2 * n + i], h = o[3 * n + i];
        ungVien.Add(new Khung(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2, diem, lop));
    }
    ungVien.Sort((a, b) => b.Diem.CompareTo(a.Diem));
    var giu = new List<Khung>();
    foreach (var k in ungVien)
        if (giu.All(g => g.Lop != k.Lop || IoU(k, g) < iouMax)) giu.Add(k);
    return giu.Select(k => k with
    {
        X1 = (k.X1 - padX) / r, Y1 = (k.Y1 - padY) / r,
        X2 = (k.X2 - padX) / r, Y2 = (k.Y2 - padY) / r
    }).ToList();
}

// Letterbox phía C#: phải giống phía huấn luyện (màu đệm, cách làm tròn, nội suy)
static (Mat anh, float r, int padX, int padY) Letterbox(Mat bgr, int dich = 640)
{
    float r = MathF.Min((float)dich / bgr.Width, (float)dich / bgr.Height);
    int nw = (int)MathF.Round(bgr.Width * r), nh = (int)MathF.Round(bgr.Height * r);
    int padX = (dich - nw) / 2, padY = (dich - nh) / 2;
    using var nho = new Mat();
    Cv2.Resize(bgr, nho, new Size(nw, nh), 0, 0, InterpolationFlags.Linear);
    var ra = new Mat();
    Cv2.CopyMakeBorder(nho, ra, padY, dich - nh - padY, padX, dich - nw - padX,
                       BorderTypes.Constant, new Scalar(114, 114, 114));
    return (ra, r, padX, padY);
}
Letterbox của thư viện huấn luyện có thể khác của bạnUltralytics đệm màu xám 114 và làm tròn phần đệm theo cách riêng; các thư viện khác có thể đặt ảnh ở góc trên trái thay vì giữa. Lệch 1 pixel đệm làm mọi khung lệch 1 pixel, đủ để một phép đo vị trí bị sai. Cách chắc chắn: đưa vài ảnh phát hiện vật vào bộ ảnh vàng, ghi khung mong đợi, và so khung bằng ghép cặp IoU như bài trước.

10. API OrtValue (ONNX Runtime 1.16 trở lên)

Các phiên bản ONNX Runtime gần đây bổ sung API dựa trên OrtValue, giảm cấp phát và chép dữ liệu, được khuyến nghị cho mã mới cần hiệu năng cao. NamedOnnxValue vẫn hoạt động; chọn một kiểu và dùng nhất quán. Kiểm tra lại tên hàm với tài liệu của đúng phiên bản bạn cài.

long[] shape = { 1, 3, 64, 64 };
var buffer = new float[3 * 64 * 64];
using var inputOrt = OrtValue.CreateTensorValueFromMemory(buffer, shape);   // dùng chung bộ nhớ với buffer
var inputs = new Dictionary<string, OrtValue> { ["images"] = inputOrt };
using var runOptions = new RunOptions();

pre.Chay(img, buffer);
using var outputs = session.Run(runOptions, inputs, session.OutputNames);
ReadOnlySpan<float> logits = outputs.First().GetTensorDataAsSpan<float>();   // đọc trực tiếp, không chép
Console.WriteLine($"logit OK {logits[0]:F5}, logit NG {logits[1]:F5}");

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

Triệu chứngNguyên nhânCách xử lý
DllNotFoundException: onnxruntime hoặc OpenCvSharpExternThiếu gói runtime gốc cho nền tảng; chạy 32-bitCài gói runtime đúng hệ điều hành; chạy tiến trình 64-bit
Lỗi "invalid dimensions" hoặc "unexpected input data type"Sai shape (thiếu trục batch, HWC thay NCHW) hoặc dùng doubleShape {1, 3, H, W}, kiểu float
Kết quả C# khác Python nhưng không lỗiTiền xử lý khác (kênh màu, nội suy, mean/std)Chạy parity test mục 7, so tổng từng kênh
Bộ nhớ tăng dần sau vài giờQuên Dispose Mat, kết quả Run, hoặc tạo session mỗi ảnhusing cho mọi đối tượng giữ bộ nhớ gốc; session tạo một lần
Lần chạy đầu 300 ms, sau đó 3 msKhởi tạo lười, cấp phát lần đầuChạy khởi động nóng vài ảnh lúc mở ứng dụng, trước khi báo sẵn sàng cho PLC
Bật GPU nhưng không nhanh hơnEP GPU không nạp được và rơi về CPU, hoặc mô hình quá nhỏ (thời gian chép dữ liệu lớn hơn thời gian tính)Ghi log EP đang dùng; đo cả hai

12. Bài tập

Bài 1 (thực hành chính). Viết ứng dụng console C# chạy mô hình phân loại OK/NG trên mọi ảnh PNG trong một thư mục, in cho mỗi ảnh: tên file, P(NG), kết luận OK/NG và thời gian xử lý (tiền xử lý + suy luận, mili giây); cuối cùng in p50/p99. Viết thêm một script Python làm đúng việc đó để có "đáp số" so sánh.

Xem đáp án
// Program.cs (cùng dự án với lớp TienXuLy ở mục 5): dotnet run -c Release -- golden
using System.Diagnostics;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;

string dir = args.Length > 0 ? args[0] : "golden";
const int S = 64;
var pre = new TienXuLy(S, new[] { 0.485f, 0.456f, 0.406f }, new[] { 0.229f, 0.224f, 0.225f });
var buffer = new float[pre.SoPhanTu];
var inputs = new List<NamedOnnxValue>
{
    NamedOnnxValue.CreateFromTensor("images", new DenseTensor<float>(buffer, new[] { 1, 3, S, S }))
};
using var opt = new SessionOptions { IntraOpNumThreads = 2 };
using var session = new InferenceSession(Path.Combine(dir, "ok_ng_cs.onnx"), opt);

string[] files = Directory.GetFiles(dir, "*.png").OrderBy(f => f).ToArray();
using (var warm = Cv2.ImRead(files[0])) for (int i = 0; i < 10; i++)   // khởi động nóng
{
    pre.Chay(warm, buffer);
    using var _ = session.Run(inputs);
}

var thoiGian = new List<double>();
foreach (string f in files)
{
    using Mat img = Cv2.ImRead(f, ImreadModes.Color);       // đọc file không tính vào thời gian
    var sw = Stopwatch.StartNew();
    pre.Chay(img, buffer);
    float[] lg;
    using (var res = session.Run(inputs)) lg = res.First().AsEnumerable<float>().ToArray();
    float p = 1f / (1f + MathF.Exp(lg[0] - lg[1]));
    sw.Stop();
    thoiGian.Add(sw.Elapsed.TotalMilliseconds);
    Console.WriteLine($"{Path.GetFileName(f)}  P(NG)={p:F5}  {(p >= 0.5f ? "NG" : "OK")}  {sw.Elapsed.TotalMilliseconds:F3} ms");
}
thoiGian.Sort();
Console.WriteLine($"p50 {thoiGian[thoiGian.Count / 2]:F3} ms, p99 {thoiGian[(int)(thoiGian.Count * 0.99)]:F3} ms");

Bản Python tham chiếu (chạy trong cùng thư mục làm việc của bài):

import time
from pathlib import Path

import cv2
import numpy as np
import onnxruntime as ort

so = ort.SessionOptions()
so.intra_op_num_threads = 2
s = ort.InferenceSession("golden/ok_ng_cs.onnx", so, providers=["CPUExecutionProvider"])
files = sorted(Path("golden").glob("*.png"))
x0 = tien_xu_ly(cv2.imread(str(files[0])))[None]
for _ in range(10):
    s.run(None, {"images": x0})                                 # khởi động nóng

tg = []
for f in files:
    img = cv2.imread(str(f))
    t0 = time.perf_counter()
    lg = s.run(None, {"images": tien_xu_ly(img)[None]})[0][0]
    p = 1.0 / (1.0 + np.exp(lg[0] - lg[1]))
    tg.append((time.perf_counter() - t0) * 1000)
    print(f"{f.name}  P(NG)={p:.5f}  {'NG' if p >= 0.5 else 'OK'}")
print(f"p50 {np.percentile(tg, 50):.3f} ms, p99 {np.percentile(tg, 99):.3f} ms (tùy máy)")
anh_00_OK.png  P(NG)=0.00000  OK
anh_01_NG.png  P(NG)=1.00000  NG
anh_02_OK.png  P(NG)=0.00000  OK
anh_03_NG.png  P(NG)=1.00000  NG
anh_04_OK.png  P(NG)=0.00001  OK
anh_05_NG.png  P(NG)=1.00000  NG
p50 0.540 ms, p99 4.446 ms (tùy máy)

Cột P(NG) và kết luận của C# phải trùng Python tới 4–5 chữ số. Thời gian của bạn sẽ khác (dòng p50/p99 ở trên tùy máy). Vì hai bên dùng chung lõi ONNX Runtime, thời gian suy luận gần như bằng nhau; chênh lệch nếu có thường nằm ở tiền xử lý. Với 6 ảnh, p99 chính là ảnh chậm nhất; khi nghiệm thu thật hãy đo hàng trăm chu kỳ.

Bài 2. Chuẩn bị sẵn "chữ ký lỗi" để chẩn đoán nhanh phía C#: với ảnh anh_01_NG.png, tính tổng từng kênh và logit khi tiền xử lý mắc từng lỗi: resize INTER_LINEAR (mặc định của Cv2.Resize), quên BGR→RGB, mean/std theo thứ tự B-G-R, quên chia 255. Lỗi nào khó phát hiện nhất nếu chỉ nhìn kết luận OK/NG?

Xem đáp án
anh = cv2.imread("golden/anh_01_NG.png")


def bien_the(anh_bgr, loi):
    noi_suy = cv2.INTER_LINEAR if loi == "resize 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
    m, s = (MEAN[::-1], STD[::-1]) if loi == "mean/std B-G-R" else (MEAN, STD)
    return np.ascontiguousarray(((a - m) / s).transpose(2, 0, 1), dtype=np.float32)


for loi in ["đúng", "resize Linear", "quên BGR->RGB", "mean/std B-G-R", "quên chia 255"]:
    t = bien_the(anh, loi)
    lg = sess.run(None, {"images": t[None]})[0][0]
    p = 1.0 / (1.0 + np.exp(lg[0] - lg[1]))
    tong = ", ".join(f"{v:.1f}" for v in t.sum(axis=(1, 2)))
    print(f"{loi:16s} tổng kênh [{tong}]  logits {np.round(lg, 3)}  P(NG) {p:.3f}")
đúng             tổng kênh [391.2, 784.0, 1576.6]  logits [-19.316  24.661]  P(NG) 1.000
resize Linear    tổng kênh [384.9, 774.3, 1571.9]  logits [-19.474  25.076]  P(NG) 1.000
quên BGR->RGB    tổng kênh [136.0, 784.0, 1836.3]  logits [11.111 -3.708]  P(NG) 0.000
mean/std B-G-R   tổng kênh [1836.3, 784.0, 136.0]  logits [-117.915  119.247]  P(NG) 1.000
quên chia 255    tổng kênh [2303189.5, 2317853.5, 2279342.5]  logits [ 443.799 1196.359]  P(NG) 1.000

Nhìn kết luận thì chỉ lỗi quên BGR→RGB lộ ra trên ảnh này (ảnh NG thành OK, vì vết gỉ cam biến thành "mực xanh"). Mean/std B-G-R và quên chia 255 vẫn ra NG nhưng logit vọt lên hàng trăm và tổng kênh lệch rất xa, nên parity test bắt được ngay; trên ảnh OK chúng sẽ cho kết luận sai. Khó phát hiện nhất là resize Linear: tổng kênh chỉ lệch khoảng 1–2%, kết luận vẫn đúng, chạy thử vài ảnh "thấy đúng" là dễ bỏ qua. Nhưng logit đã lệch tới 0,4, gấp hàng chục nghìn lần sai số làm tròn, và với ảnh sát ngưỡng nó đủ đổi quyết định. Lưu ý chữ ký của hai lỗi đổi kênh: kênh 1 (G) giữ nguyên 784,0, kênh 0 và 2 thay đổi, đúng như bảng ở mục 7.

Bài 3. Camera 5 MP cho ảnh 2448×2048, mô hình phát hiện vật đầu vào 640×640 dùng letterbox như mục 9. (a) Tính tỉ lệ r, kích thước ảnh sau thu nhỏ, phần đệm. (b) Mô hình end-to-end trả khung (100, 200, 180, 260) trong ảnh letterbox; khung đó ở đâu trên ảnh gốc? (c) Một pixel trên ảnh 640 tương ứng bao nhiêu pixel ảnh gốc, và điều đó nói gì về việc dùng khung phát hiện để đo kích thước?

Xem đáp án
r, pad_x, pad_y = letterbox_thong_so(2448, 2048)
print(f"r = {r:.5f}, ảnh thu nhỏ {round(2448 * r)}x{round(2048 * r)}, đệm trái {pad_x}, đệm trên {pad_y}")
x1, y1, x2, y2 = 100, 200, 180, 260
goc = [(x1 - pad_x) / r, (y1 - pad_y) / r, (x2 - pad_x) / r, (y2 - pad_y) / r]
print("khung trên ảnh gốc:", [round(v, 1) for v in goc])
print(f"1 pixel ảnh 640 = {1 / r:.2f} pixel ảnh gốc")
r = 0.26144, ảnh thu nhỏ 640x535, đệm trái 0, đệm trên 52
khung trên ảnh gốc: [382.5, 566.1, 688.5, 795.6]
1 pixel ảnh 640 = 3.82 pixel ảnh gốc

(c) Mỗi pixel đầu vào mô hình ứng với gần 4 pixel ảnh gốc, nên khung phát hiện chỉ chính xác cỡ vài pixel ảnh gốc. Dùng khung để định vị vùng quan tâm, rồi đo kích thước bằng công cụ đo subpixel trên ảnh gốc độ phân giải đầy đủ (như chặng Measurement), không đo trực tiếp trên khung của mô hình.

Tóm tắt

  • Gói Microsoft.ML.OnnxRuntime cho CPU; GPU NVIDIA dùng Microsoft.ML.OnnxRuntime.Gpu (thay thế, không cài cùng); Windows mọi GPU dùng .DirectML; Intel dùng gói OpenVINO EP.
  • InferenceSession tạo một lần, dùng chung (Run an toàn đa luồng); SessionOptions đặt mức tối ưu đồ thị, số luồng, EP. Mọi đối tượng giữ bộ nhớ gốc (session, options, kết quả Run, Mat) phải nằm trong using.
  • Tiền xử lý C# phải giống hệt Python: nội suy Area, BGR→RGB, ConvertTo(alpha = 1/(255·std), beta = −mean/std) từng kênh, chép từng kênh liền khối để có NCHW.
  • DenseTensor<float> bọc một float[] dùng lại cho mọi ảnh; NamedOnnxValue.CreateFromTensor + session.Run; chép đầu ra ra mảng trước khi Dispose. API OrtValue là lựa chọn mới hơn.
  • Phân loại: softmax/sigmoid rồi ngưỡng. Phát hiện vật: in shape đầu ra, giải mã đúng dạng (thô cần NMS, end-to-end không cần), bỏ letterbox để về tọa độ ảnh gốc.
  • Parity test với bộ ảnh vàng (mã băm mô hình, logit trong dung sai, tổng từng kênh khi lệch) là điều kiện để lắp chương trình lên trạm.

Bài tiếp theo: Tăng tốc suy luận: OpenVINO, TensorRT, FP16, INT8, đo tốc độ đúng cách và chọn runtime theo phần cứng.

Tham khảo: ONNX Runtime: Get started with C#.

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

  1. Chạy mô hình phân loại OK/NG của bạn trong ứng dụng console C#, in kết quả và thời gian xử lý.

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

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

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