pcb-defect-api / pcb_infer.py
michaelandrew35's picture
Deploy c2f35a3b from GitLab CI
9a12c0e verified
Raw History Blame Contribute Delete
6.81 kB
"""Shared ONNX Runtime inference for the PCB defect detector.
Used by both the FastAPI service (serve/) and the Gradio demo (app/app.py), so the
two always run the same preprocessing, decoding and NMS. The steps mirror
Ultralytics 8.3 (LetterBox, non_max_suppression, scale_boxes) so results match the
evaluation pipeline in src/.
Only numpy, OpenCV and onnxruntime are needed: no PyTorch, no Ultralytics.
"""
from __future__ import annotations
import ast
import time
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
import onnxruntime as ort
FALLBACK_NAMES = ["missing_hole", "mouse_bite", "open_circuit", "short", "spur", "spurious_copper"]
COLORS = [(56, 56, 255), (151, 157, 255), (31, 112, 255), (29, 178, 255), (49, 210, 207), (10, 249, 72)] # BGR
MAX_WH = 7680 # class offset for batched NMS, as in Ultralytics
@dataclass(frozen=True)
class Detection:
class_id: int
class_name: str
confidence: float
box: tuple[float, float, float, float] # x1, y1, x2, y2 in original-image pixels
def to_dict(self) -> dict:
return {
"class": self.class_name,
"class_id": self.class_id,
"confidence": round(self.confidence, 4),
"box": [round(v, 1) for v in self.box],
}
@dataclass(frozen=True)
class Timings:
preprocess_ms: float
model_ms: float
postprocess_ms: float
@property
def total_ms(self) -> float:
return self.preprocess_ms + self.model_ms + self.postprocess_ms
def letterbox(bgr: np.ndarray, size: int) -> tuple[np.ndarray, float, tuple[float, float]]:
"""Ultralytics LetterBox(auto=False, center=True): resize keeping aspect ratio, pad with 114."""
h, w = bgr.shape[:2]
r = min(size / h, size / w)
new_w, new_h = int(round(w * r)), int(round(h * r))
dw, dh = (size - new_w) / 2, (size - new_h) / 2
img = bgr if (w, h) == (new_w, new_h) else cv2.resize(bgr, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
return img, r, (dw, dh)
def nms(boxes: np.ndarray, scores: np.ndarray, iou_thres: float) -> np.ndarray:
"""Greedy NMS (same rule as torchvision.ops.nms). boxes: (n, 4) xyxy. Returns kept indices."""
order = scores.argsort()[::-1]
x1, y1, x2, y2 = boxes.T
areas = (x2 - x1) * (y2 - y1)
keep = []
while order.size:
i = order[0]
keep.append(i)
rest = order[1:]
iw = np.clip(np.minimum(x2[i], x2[rest]) - np.maximum(x1[i], x1[rest]), 0, None)
ih = np.clip(np.minimum(y2[i], y2[rest]) - np.maximum(y1[i], y1[rest]), 0, None)
inter = iw * ih
iou = inter / (areas[i] + areas[rest] - inter + 1e-9)
order = rest[iou <= iou_thres]
return np.asarray(keep, dtype=int)
class Detector:
"""Loads the ONNX model once; predict() is safe to call from several threads."""
def __init__(self, model_path: str | Path, threads: int | None = None):
self.model_path = Path(model_path)
opts = ort.SessionOptions()
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
if threads:
opts.intra_op_num_threads = threads
opts.inter_op_num_threads = 1
self.session = ort.InferenceSession(str(self.model_path), opts, providers=["CPUExecutionProvider"])
inp = self.session.get_inputs()[0]
self.input_name = inp.name
meta = self.session.get_modelmeta().custom_metadata_map
self.imgsz = inp.shape[2] if isinstance(inp.shape[2], int) else ast.literal_eval(meta.get("imgsz", "[1280]"))[0]
names = ast.literal_eval(meta["names"]) if "names" in meta else dict(enumerate(FALLBACK_NAMES))
self.names = {int(k): v for k, v in names.items()}
# --- pipeline steps -------------------------------------------------------
def preprocess(self, bgr: np.ndarray):
img, r, pad = letterbox(bgr, self.imgsz)
x = np.ascontiguousarray(img[:, :, ::-1].transpose(2, 0, 1), dtype=np.float32)[None] / 255.0
return x, r, pad
def infer(self, x: np.ndarray) -> np.ndarray:
return self.session.run(None, {self.input_name: x})[0]
def postprocess(self, raw, r, pad, shape, conf=0.25, iou=0.7, max_det=300) -> list[Detection]:
pred = raw[0].T # (anchors, 4 + num_classes)
scores_all = pred[:, 4:]
cls = scores_all.argmax(1)
scores = scores_all[np.arange(len(cls)), cls]
keep = scores > conf
if not keep.any():
return []
xywh, cls, scores = pred[keep, :4], cls[keep], scores[keep]
boxes = np.empty_like(xywh)
boxes[:, 0] = xywh[:, 0] - xywh[:, 2] / 2
boxes[:, 1] = xywh[:, 1] - xywh[:, 3] / 2
boxes[:, 2] = xywh[:, 0] + xywh[:, 2] / 2
boxes[:, 3] = xywh[:, 1] + xywh[:, 3] / 2
idx = nms(boxes + cls[:, None] * MAX_WH, scores, iou)[:max_det]
boxes, cls, scores = boxes[idx], cls[idx], scores[idx]
# Ultralytics scale_boxes: undo padding and resize, then clip to the image.
h, w = shape
pad_x = round((self.imgsz - w * r) / 2 - 0.1)
pad_y = round((self.imgsz - h * r) / 2 - 0.1)
boxes = (boxes - [pad_x, pad_y, pad_x, pad_y]) / r
boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, w)
boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, h)
return [
Detection(int(c), self.names.get(int(c), str(c)), float(s), tuple(float(v) for v in b))
for b, c, s in zip(boxes, cls, scores)
]
def predict(self, bgr: np.ndarray, conf: float = 0.25, iou: float = 0.7) -> tuple[list[Detection], Timings]:
t0 = time.perf_counter()
x, r, pad = self.preprocess(bgr)
t1 = time.perf_counter()
raw = self.infer(x)
t2 = time.perf_counter()
dets = self.postprocess(raw, r, pad, bgr.shape[:2], conf, iou)
t3 = time.perf_counter()
return dets, Timings((t1 - t0) * 1000, (t2 - t1) * 1000, (t3 - t2) * 1000)
def draw(bgr: np.ndarray, dets: list[Detection]) -> np.ndarray:
"""Return a copy of the image with boxes and labels drawn on it."""
out = bgr.copy()
thick = max(2, round(max(out.shape[:2]) / 600))
for d in dets:
x1, y1, x2, y2 = (int(v) for v in d.box)
color = COLORS[d.class_id % len(COLORS)]
cv2.rectangle(out, (x1, y1), (x2, y2), color, thick)
cv2.putText(out, f"{d.class_name} {d.confidence:.2f}", (x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX, thick / 2.5, color, thick, cv2.LINE_AA)
return out