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.
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. Bức tranh tổng thể: Python huấn luyện, C# chạy trên trạm
- 2. Phía Python: mô hình nhỏ và bộ ảnh vàng
- 3. Tạo dự án C# và cài gói
- 4. InferenceSession: tạo một lần, đọc "hợp đồng" của mô hình
- 5. Tiền xử lý trong C#: giống hệt Python, từng bước
- 6. Chạy suy luận: DenseTensor, NamedOnnxValue, đo thời gian
- 7. Chương trình parity test với bộ ảnh vàng
- 8. SessionOptions và Execution Provider
- 9. Giải mã đầu ra phát hiện vật: letterbox, NMS, đổi tọa độ
- 10. API OrtValue (ONNX Runtime 1.16 trở lên)
- 11. Lỗi thường gặp
- 12. Bài tập
- 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.
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 NuGet | Chạy trên | Ghi chú |
|---|---|---|
Microsoft.ML.OnnxRuntime | CPU (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.Gpu | GPU 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.DirectML | Windows, 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.OpenVino | CPU, GPU tích hợp, NPU của Intel | Gói do Intel phát hành, dùng OpenVINO Execution Provider |
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ã.
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ước | Python (lúc huấn luyện) | C# (OpenCvSharp) | Bẫy hay gặp |
|---|---|---|---|
| 1. Resize | cv2.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ênh | COLOR_BGR2RGB | ColorConversionCodes.BGR2RGB | OpenCvSharp đọc ảnh BGR giống OpenCV; quên đổi là lỗi số một |
| 3. Chia 255 | / 255.0 | gộ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, B | alpha = 1/(255·std[c]), beta = -mean[c]/std[c] | Áp mean/std theo thứ tự B, G, R |
| 5. HWC→CHW | transpose(2, 0, 1) | Cv2.Split rồi chép từng kênh liền nhau | Ché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ùngsession.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:
resultstrả 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ốiusingkế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ứng | Nguyên nhân khả dĩ |
|---|---|
| Tổng kênh 0 và 2 tráo nhau gần đúng, kênh 1 khớp | Quê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-1 | Sai 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ần | Quên chia 255 hoặc quên mean/std |
| Tổng kênh khớp mà logit lệch | Sai 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ĩa | Gợi ý cho trạm vision |
|---|---|---|
GraphOptimizationLevel | Mứ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) |
IntraOpNumThreads | Số 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, ExecutionMode | Chạ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ứng | Gọi trong khối try/catch; ghi log EP thực sự được dùng lúc khởi động |
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ạng | Shape ví dụ | Nội dung | Cầ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ớp | Lọ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ớp | Lọ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);
}
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ứng | Nguyên nhân | Cách xử lý |
|---|---|---|
DllNotFoundException: onnxruntime hoặc OpenCvSharpExtern | Thiếu gói runtime gốc cho nền tảng; chạy 32-bit | Cà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 double | Shape {1, 3, H, W}, kiểu float |
| Kết quả C# khác Python nhưng không lỗi | Tiề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 ảnh | using 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 ms | Khởi tạo lười, cấp phát lần đầu | Chạ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ơn | EP 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.OnnxRuntimecho CPU; GPU NVIDIA dùngMicrosoft.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. InferenceSessiontạ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 trongusing.- 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ộtfloat[]dùng lại cho mọi ảnh;NamedOnnxValue.CreateFromTensor+session.Run; chép đầu ra ra mảng trước khi Dispose. APIOrtValuelà 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
- 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.


