PLC高级实战:印刷标志与工业质检异常检测完整流程(下半部份)
工业质检必修的机器视觉入门课程 —— 从数据标注到产线部署,一套代码打通"旋转目标检测 + 异常检测"双模型流水线。
技术栈:ultralytics(YOLO11-OBB)+onnxruntime+anomalib>=1.0+OpenCV
全文代码基于课程实录整理,标注【补全】/【扩展】的片段为整理者按工程规范补充,复现时如遇 API 差异以所用库版本文档为准。
一、我们的目的:做什么例子
工业现场有一块玻璃,上面印着印刷标志(logo)。产线要求机器自动回答三个问题:
- 标志在哪? —— 印刷位置可能偏移,且玻璃上每个标志的安装角度不一致
- 歪了多少? —— 必须知道旋转角度,才能把标志"转正"成统一方向
- 印得好不好? —— 转正后的标志有没有缺印、划痕、颜色异常等缺陷
靠人工目检?慢、累、标准不统一。本课程用两个深度学习模型分工解决:
|
模型 |
职责 |
技术 |
|---|---|---|
|
YOLO11-OBB(旋转目标检测) |
解决"在哪、歪多少" |
带角度的旋转框检测 |
|
Anomalib Padim(异常检测) |
解决"正了之后好不好" |
只学正常样本的无监督异常检测 |
为什么必须两个模型? 缺陷检测模型对方向敏感——标志歪 30° 和缺一角,在像素层面可能"看起来一样异常"。先把所有标识用 OBB 检出并转正对齐,Anomalib 才能专注于"真正像缺陷的差异"。
二、总体步骤
步骤1 标注 labelme 标 polygon/矩形 → 转 YOLO OBB 格式(最小外接旋转矩形)
步骤2 训练 YOLO11-OBB 训练(小目标,imgsz=1024)→ 导出 ONNX
步骤3 部署 纯 ONNX Runtime 推理:letterbox 预处理 + NMSBoxesRotated 解码
步骤4 制数据集 批量检测大图 → 仿射转正裁剪 → 方向一致的 cropped_boxes 小图
步骤5 训练 Anomalib Padim 只学"正常标志" → 导出 ONNX(CPU 可跑)
步骤6 端到端 大图进 → OBB 定位 → 转正 → Padim 打分 → 输出异常分数
六个步骤对应工业质检落地的标准路径:标注 → 检测训练 → 检测部署 → 数据标准化 → 异常训练 → 产线融合。
三、深入步骤 1~3:YOLO11 OBB 旋转目标检测
3.1 环境安装
pip install ultralytics
import ultralytics
ultralytics.checks() # 检查环境并显示版本信息
3.2 加载预训练 OBB 模型
from ultralytics import YOLO
model = YOLO("yolo11n-obb.pt") # 轻量级,本地无权重自动下载;可换 yolo11s/m/l/x
3.3 标注转换:labelme → YOLO OBB(步骤 1 核心)
YOLO OBB 标签格式:每行 class_id x1 y1 x2 y2 x3 y3 x4 y4,8 坐标归一化到 0~1。
labelme2yolo_obb.py(完整可运行):
import glob
import json
import random
import shutil
from pathlib import Path
import cv2
import numpy as np
from tqdm import tqdm
class LabelMeToYOLOOBBConverter:
def __init__(self, input_dir, output_dir, train_ratio=0.8, random_seed=42):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
self.train_ratio = train_ratio
self.random_seed = random_seed
self.classes_dict = {}
random.seed(self.random_seed)
# ---------- 图像/几何工具 ----------
def _read_image_with_chinese_path(self, image_path):
# 支持中文路径的图像读取(cv2.imread 不支持中文路径)
try:
raw_data = np.fromfile(image_path, dtype=np.uint8)
return cv2.imdecode(raw_data, cv2.IMREAD_COLOR)
except Exception as e:
print(f"读取图像失败 {image_path}: {e}")
return None
def _clip_coordinate_to_boundary(self, coordinate, max_value):
return max(0.0, min(coordinate, max_value - 1))
def _normalize_points(self, points, image_width, image_height):
normalized = []
for x, y in points:
normalized.extend([x / image_width, y / image_height])
return normalized
def _polygon_to_obb_points(self, polygon_points, image_width, image_height):
# 多边形点 → 最小外接旋转矩形四个角点(归一化前)
if len(polygon_points) < 3:
return None
pts = np.array(polygon_points, dtype=np.float32)
rect = cv2.minAreaRect(pts) # (中心, 宽高, 角度)
box = cv2.boxPoints(rect) # (4, 2)
clipped_box = []
for x, y in box:
x_clip = self._clip_coordinate_to_boundary(x, image_width)
y_clip = self._clip_coordinate_to_boundary(y, image_height)
clipped_box.append((x_clip, y_clip))
return clipped_box
# ---------- 数据准备 ----------
def _extract_classes_from_annotations(self):
# 从所有 JSON 中提取类别(跳过 bad 标签)
classes_set = set()
json_files = glob.glob(str(self.input_dir / "*.json"))
for json_file in tqdm(json_files, desc="提取类别信息"):
try:
with open(json_file, "r", encoding="utf-8") as f:
annotation_data = json.load(f)
for shape in annotation_data.get("shapes", []):
label = shape.get("label", "")
if label and label != "bad":
classes_set.add(label)
except Exception as e:
print(f"处理文件 {json_file} 时出错: {e}")
continue
sorted_classes = sorted(list(classes_set))
self.classes_dict = {name: idx for idx, name in enumerate(sorted_classes)}
print(f"检测到 {len(self.classes_dict)} 个类别: {self.classes_dict}")
return self.classes_dict
def _create_output_directories(self):
dirs = [
self.output_dir / "images" / "train",
self.output_dir / "images" / "val",
self.output_dir / "labels" / "train",
self.output_dir / "labels" / "val",
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
print(f"创建目录: {d}")
def _split_dataset_files(self, json_files):
shuffled = json_files.copy()
random.shuffle(shuffled)
split = int(len(shuffled) * self.train_ratio)
return shuffled[:split], shuffled[split:]
def _find_image_file(self, json_path):
json_path = Path(json_path)
for ext in [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"]:
img_path = json_path.with_suffix(ext)
if img_path.exists():
return img_path
return None
# ---------- 单文件转换 ----------
def _convert_single_annotation(self, json_path, label_dir, image_dir):
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
img_path = self._find_image_file(json_path)
if not img_path:
return False
img = self._read_image_with_chinese_path(img_path)
if img is None:
return False
h, w = img.shape[:2]
txt_path = label_dir / (Path(json_path).stem + ".txt")
with open(txt_path, "w", encoding="utf-8") as out_f:
for shape in data.get("shapes", []):
label = shape.get("label", "")
if label not in self.classes_dict:
continue
class_id = self.classes_dict[label]
points = shape.get("points", [])
shape_type = shape.get("shape_type", "polygon")
if shape_type == "polygon":
if len(points) < 3:
continue
poly_pts = points
elif shape_type == "rectangle":
if len(points) != 2:
continue
x1, y1 = points[0]
x2, y2 = points[1]
poly_pts = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
else:
print(f"警告: 不支持的形状类型 {shape_type}, 跳过")
continue
obb_corners = self._polygon_to_obb_points(poly_pts, w, h)
if obb_corners is None:
continue
norm_corners = self._normalize_points(obb_corners, w, h)
if len(norm_corners) != 8:
continue
line = f"{class_id} " + " ".join(f"{c:.6f}" for c in norm_corners)
out_f.write(line)
shutil.copy2(img_path, image_dir)
return True
except Exception as e:
print(f"转换文件 {json_path} 时出错: {e}")
return False
def _generate_yolo_config_file(self):
# 【补全】生成 Ultralytics 训练所需 data.yaml
names = sorted(self.classes_dict, key=self.classes_dict.get)
yaml_path = self.output_dir / "data.yaml"
content = (
f"path: {self.output_dir.resolve()}\n"
f"train: images/train\n"
f"val: images/val\n"
f"nc: {len(names)}\n"
f"names: {names}\n"
)
with open(yaml_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"生成配置文件: {yaml_path}")
def _print_statistics(self, total, train_succ, val_succ, train_total, val_total):
print(f"总文件数: {total}, 训练成功: {train_succ}/{train_total}, "
f"验证成功: {val_succ}/{val_total}") # 【补全】统计行
print(f"类别数量: {len(self.classes_dict)}")
print(f"输出目录: {self.output_dir}")
print("=" * 60)
def convert(self):
print("开始 LabelMe → YOLO OBB 转换...")
self._extract_classes_from_annotations()
if not self.classes_dict:
print("错误: 未找到任何有效类别")
return False
self._create_output_directories()
json_files = glob.glob(str(self.input_dir / "*.json"))
if not json_files:
print("错误: 未找到JSON文件")
return False
print(f"找到 {len(json_files)} 个JSON文件")
train_files, val_files = self._split_dataset_files(json_files)
print(f"数据集分割: 训练集 {len(train_files)}, 验证集 {len(val_files)}")
train_succ = 0
for jf in tqdm(train_files, desc="转换训练集"):
if self._convert_single_annotation(
jf, self.output_dir / "labels" / "train",
self.output_dir / "images" / "train"):
train_succ += 1
val_succ = 0
for jf in tqdm(val_files, desc="转换验证集"):
if self._convert_single_annotation(
jf, self.output_dir / "labels" / "val",
self.output_dir / "images" / "val"):
val_succ += 1
self._generate_yolo_config_file()
self._print_statistics(len(json_files), train_succ, val_succ,
len(train_files), len(val_files))
return True
def main():
CONFIG = {
"input_dir": "./datasets/labelme/glass_logo", # LabelMe JSON 所在目录
"output_dir": "./datasets/yolo_obb_glass_logo", # 输出目录(自动创建)
"train_ratio": 0.8,
"random_seed": 42,
}
converter = LabelMeToYOLOOBBConverter(**CONFIG)
if not converter.convert():
print("转换失败, 请检查输入数据和配置")
return 1
return 0
if __name__ == "__main__":
exit(main())
转换脚本要点:
- "bad" 标签过滤:标注时的废弃框,转换时跳过
- 中文路径:
cv2.imread不支持中文路径,用np.fromfile + cv2.imdecode替代 - rectangle 也能转:labelme 矩形标注先转 4 点 →
minAreaRect,混合标注格式无压力 - 类别按字母序映射索引:换数据集必须重新核对
data.yaml中names顺序 - 静默跳过项:
<3 点 / 标签未登记 / 坐标越界 / 角点数 ≠ 8,转换完必须看统计输出
3.4 可视化验证转换结果
训练前必须验证:画一张图,看旋转框是否贴合印刷标志。
import numpy as np
import cv2
from PIL import Image, ImageDraw, ImageFont
def draw_obb_on_image(image_path, label_path, class_names=None):
# 在图像上绘制 OBB 标注框,验证转换是否正确
img = Image.open(image_path).convert("RGB")
draw = ImageDraw.Draw(img)
w, h = img.size
font = ImageFont.truetype("chinese_cht.ttf", 20) # 中文字体,路径按实际修改
with open(label_path, "r") as f:
lines = f.readlines()
for line in lines:
parts = line.strip().split()
if len(parts) != 9: # 1 id + 8 坐标
continue
cls_id = int(parts[0])
points = list(map(float, parts[1:]))
pixel_points = []
for i in range(0, len(points), 2): # 归一化 → 像素
pixel_points.append((int(points[i] * w), int(points[i + 1] * h)))
draw.polygon(pixel_points, outline="red", width=3)
text = class_names[cls_id] if (class_names and cls_id < len(class_names)) else str(cls_id)
draw.text(pixel_points[0], text, fill="white", font=font)
return img
img = draw_obb_on_image(
"datasets/yolo_obb_glass_logo/images/train/xxx_NG.jpg",
"datasets/yolo_obb_glass_logo/labels/train/xxx_NG.txt",
class_names=["logo"],
)
img.save("check_obb.jpg")
类别名是中文必须用 PIL 绘制(cv2.putText不支持中文);字体文件路径改为本机绝对路径,如C:/Windows/Fonts/msyh.ttc。
3.5 训练(步骤 2)
from ultralytics import YOLO
model = YOLO("yolo11n-obb.pt")
model.train(
data="datasets/yolo_obb_glass_logo/data.yaml",
epochs=50, # 演示轮次;实际工业场景建议 100~300
imgsz=1024, # 【补全】印刷标志是小目标,用 1024 而非默认 640
batch=16,
device=0,
)
训练输出在runs/obb/train/(重复训练自动递增 train2、train3…),最佳权重weights/best.pt。
3.6 导出 ONNX
model = YOLO("runs/obb/train/weights/best.pt")
model.export(format="onnx", imgsz=1024) # 【补全】导出为 best.onnx
3.7 推理:三种方式(步骤 3)
方式一:直接推理 —— 开发调试最快
res = model("datasets/yolo_obb_glass_logo/images/val/xxx_NG.jpg")
res[0].save("output.jpg")
方式二:Ultralytics 加载 ONNX —— 验证导出模型是否正确
model_onnx = YOLO("runs/obb/train/weights/best.onnx")
res_onnx = model_onnx(test_image)
res_onnx[0].save("output_onnx.jpg")
方式三:纯 ONNX Runtime —— 产线部署推荐,完全脱离 ultralytics 依赖
预处理——letterbox 等比缩放 + 114 填充 + HWC→NCHW:
import onnxruntime as ort
session = ort.InferenceSession("runs/obb/train2/weights/best.onnx")
input_name = session.get_inputs()[0].name
def preprocess(img, target_size=(1024, 1024)):
# 注意:target_size 必须与训练 imgsz 一致(本例 1024)
original_shape = img.shape[:2]
r = min(target_size[0] / original_shape[0],
target_size[1] / original_shape[1])
new_unpad = (int(original_shape[1] * r), int(original_shape[0] * r))
img_resized = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
dw = target_size[1] - new_unpad[0]
dh = target_size[0] - new_unpad[1]
top, bottom = dh // 2, dh - dh // 2
left, right = dw // 2, dw - dw // 2
img_padded = cv2.copyMakeBorder(img_resized, top, bottom, left, right,
cv2.BORDER_CONSTANT, value=(114, 114, 114))
img_data = img_padded.astype(np.float32) / 255.0
img_data = np.transpose(img_data, (2, 0, 1)) # CHW
img_data = np.expand_dims(img_data, axis=0) # NCHW
return img_data, (top, left), original_shape, r, img_padded
后处理——解包 6 维输出 + 反 letterbox + 旋转框 NMS:
def postprocess(outputs, pad_info, r, conf_thres=0.25):
pred = outputs[0].squeeze(0).T # (num_boxes, 6)
top, left = pad_info
valid_mask = pred[:, 4] > conf_thres
pred = pred[valid_mask]
if len(pred) == 0:
return []
cx, cy, w, h, score, angle_rad = pred.T
cx = (cx - left) / r # 反 letterbox,回到原图坐标
cy = (cy - top) / r
w = w / r
h = h / r
angle_deg = angle_rad * 180 / np.pi # 弧度 → 角度
boxes_5d = np.column_stack([cx, cy, w, h, angle_deg]).astype(np.float32)
boxes = [((float(b[0]), float(b[1])), (float(b[2]), float(b[3])), float(b[4]))
for b in boxes_5d]
scores = score.tolist()
indices = cv2.dnn.NMSBoxesRotated(boxes, scores,
score_threshold=0.5, nms_threshold=0.5, top_k=0)
keep_indices = np.array(indices).flatten() # 必须 flatten,返回的是 [[i]] 嵌套结构
final_results = []
for i in keep_indices:
i = int(i)
rect = ((cx[i], cy[i]), (w[i], h[i]), angle_deg[i])
box = cv2.boxPoints(rect).astype(np.int32) # 4 顶点 (4,2)
final_results.append({
"cx": cx[i], "cy": cy[i], "w": w[i], "h": h[i],
"score": score[i],
"angle_rad": angle_rad[i], "angle_deg": angle_deg[i],
"box": box, # 4 顶点坐标 (4,2)
"box_5d": boxes_5d[i], # 5 维旋转框参数
})
return final_results
画框验证:
top, left = pad_info
img = img_padded[top: top + int(original_shape[0] * r),
left: left + int(original_shape[1] * r)]
img = cv2.resize(img, (original_shape[1], original_shape[0]))
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
for res in final_results:
cv2.drawContours(img, [res["box"]], 0, (0, 255, 0), 2)
text = f"{res['score']:.2f} {res['angle_deg']:.0f}"
cv2.putText(img, text, (int(res["cx"]) - 30, int(res["cy"])),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)
Image.fromarray(img)
后处理要点:
- 单类别输出 6 维:
cx cy w h score angle(弧度),无 class 维度;多类别会多 nc 维,解包方式不同 cv2.dnn.NMSBoxesRotated是旋转框专用 NMS,普通 NMSBoxes 基于水平 IoU 会失效NMSBoxesRotated返回嵌套结构,必须flatten(),否则int(i)报错- 双重阈值:
conf_thres=0.25过滤 + NMS 内score_threshold=0.5,工业场景可按误检/漏检偏好调整
四、深入步骤 4:批量提取印刷标志,转正裁剪构建数据集
目的:Anomalib 学的是"正常样本的分布",必须先把方向不一的标志转正对齐,缺陷差异才不被旋转角度淹没。
extract_rotated_boxes.py(复用 3.7 的 postprocess,preprocess 改为接收 ndarray):
import os
import glob
from pathlib import Path
import numpy as np
import cv2
import onnxruntime as ort
from tqdm import tqdm
onnx_model_path = "runs/obb/train2/weights/best.onnx"
session = ort.InferenceSession(onnx_model_path)
input_name = session.get_inputs()[0].name
def preprocess(img, target_size=(1024, 1024)):
# 入参为 BGR ndarray,额外保留原图供裁剪
original_shape = img.shape[:2]
original_img = img.copy()
r = min(target_size[0] / original_shape[0],
target_size[1] / original_shape[1])
new_unpad = (int(original_shape[1] * r), int(original_shape[0] * r))
img_resized = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
dw = target_size[1] - new_unpad[0]
dh = target_size[0] - new_unpad[1]
top, bottom = dh // 2, dh - dh // 2
left, right = dw // 2, dw - dw // 2
img_padded = cv2.copyMakeBorder(img_resized, top, bottom, left, right,
cv2.BORDER_CONSTANT, value=(114, 114, 114))
img_data = img_padded.astype(np.float32) / 255.0
img_data = np.transpose(img_data, (2, 0, 1))
img_data = np.expand_dims(img_data, axis=0)
return img_data, original_img, (top, left), original_shape, r
def rotate_and_crop_rect(img, center, w, h, angle_deg, padding=10):
# 倾斜旋转框 → 正向 w×h 小图
half_w, half_h = w / 2, h / 2
pts = np.array(
[[-half_w, -half_h], [half_w, -half_h],
[half_w, half_h], [-half_w, half_h]],
dtype=np.float32,
)
theta = np.radians(angle_deg)
rot_mat = np.array(
[[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]], dtype=np.float32,
)
pts_rotated = (rot_mat @ pts.T).T
cx, cy = center
pts_abs = pts_rotated + np.array([cx, cy])
# 外接正矩形 + padding,夹紧到图内
x_min, y_min = int(np.min(pts_abs[:, 0])), int(np.min(pts_abs[:, 1]))
x_max, y_max = int(np.max(pts_abs[:, 0])), int(np.max(pts_abs[:, 1]))
x1 = max(0, x_min - padding)
y1 = max(0, y_min - padding)
x2 = min(img.shape[1], x_max + padding)
y2 = min(img.shape[0], y_max + padding)
crop = img[y1:y2, x1:x2]
# 仿射拉正(前 3 点确定仿射矩阵)
pts_rel = pts_abs - np.array([x1, y1])
dst_pts = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32)
M = cv2.getAffineTransform(pts_rel[:3].astype(np.float32),
dst_pts[:3].astype(np.float32))
rotated = cv2.warpAffine(crop, M, (int(w), int(h)), flags=cv2.INTER_LINEAR)
return rotated
def save_rotated_boxes(img, results, save_dir="rotated_boxes", base_name="box"):
os.makedirs(save_dir, exist_ok=True)
for idx, res in enumerate(results):
rect_img = rotate_and_crop_rect(
img, (res["cx"], res["cy"]), res["w"], res["h"],
res["angle_deg"], padding=10,
)
save_path = os.path.join(save_dir, f"{base_name}_{idx}.png")
cv2.imwrite(save_path, rect_img)
if __name__ == "__main__":
img_list = glob.glob("datasets/labelme/glass_logo/*.jpg") # 按需改后缀
save_dir = "datasets/cropped_boxes"
os.makedirs(save_dir, exist_ok=True)
for img_path in tqdm(img_list):
base_name = Path(img_path).stem
img = cv2.imread(img_path)
img_data, original_img, pad_info, original_shape, r = preprocess(img)
outputs = session.run(None, {input_name: img_data})
results = postprocess(outputs, pad_info, r, conf_thres=0.25)
save_rotated_boxes(original_img, results,
save_dir=save_dir, base_name=base_name)
要点:裁剪必须用 original_img(postprocess 返回的 cx/cy/w/h 已是原图坐标);转正用"旋转矩阵求角点 → 外接矩形裁剪 → 仿射拉正",只对 ROI 做一次仿射,比旋转整图快得多。
五、深入步骤 5:Anomalib 异常检测
5.1 安装与数据集组织
pip install "anomalib>=1.0" # 1.x 才是 Engine API;旧版 Inferencer 写法不同
Anomalib Folder 数据格式(test/bad 下的 labelme JSON 会被自动读作 GT mask):
datasets/addata/glasslogo/
├── train/
│ └── good/ # 正常转正小图(cropped_boxes 中筛出的 OK 样本)
└── test/
└── bad/ # 缺陷小图 + 同名 .json(polygon 标注缺陷区域)
工业惯例:OK = 良品,NG = 不良品。train/good 只放正常样本——异常检测是"只学正常",不需要缺陷样本参与训练。
5.2 训练与评估【补全】
课程未展开此段,以下按 anomalib 1.x 标准流程补全(Padim:无需缺陷训练样本、推理快,适合工业落地):
from anomalib.data import Folder
from anomalib.models import Padim
from anomalib.engine import Engine
datamodule = Folder(
root="datasets/addata/glasslogo",
normal_dir="train/good",
abnormal_dir="test/bad", # bad 下同名 labelme json 自动作为 GT mask
image_size=(256, 256), # 【补全】按显卡显存调整
train_batch_size=32,
eval_batch_size=32,
)
datamodule.setup()
model = Padim()
engine = Engine()
engine.fit(model=model, datamodule=datamodule) # 只在 good 上训练
engine.test(model=model, datamodule=datamodule) # 自动算 image/pixel AUROC、PRO 等
训练产物在 results/Padim/MVTecAD/glass_logo/ 目录。导出 ONNX 供 5.4 节使用:
engine.export(model=model, export_type="onnx") # 生成 weights/onnx/model.onnx
5.3 推理:异常热力图与 GT 掩码
from matplotlib import pyplot as plt
img_path = 'datasets/addata/glasslogo/test/bad/xxx_NG_001_0.png'
predictions = engine.predict(model=model, data_path=img_path)
prediction = predictions[0] # 只输入一张图,取第 0 个
anomaly_map = prediction.anomaly_map # 异常热力图(像素级分数)
pred_score = prediction.pred_score # 图像级异常分数
print(f"异常分数: {pred_score:.4f}") # 【补全】与训练得到的阈值比较判异
plt.figure(figsize=(6, 6))
plt.imshow(anomaly_map, cmap="jet") # 红=异常, 蓝=正常
plt.axis("off")
plt.show()
GT 掩码生成(手动算 IoU/Dice 时,或版本未自动读 labelme json 时):
import json
json_path = 'datasets/addata/glasslogo/test/bad/xxx_NG_001_0.json'
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
img_height = data['imageHeight']
img_width = data['imageWidth']
mask = np.zeros((img_height, img_width), dtype=np.uint8)
for shape in data['shapes']:
if shape['shape_type'] == 'polygon':
points = np.array(shape['points'], dtype=np.int32).reshape(-1, 1, 2)
cv2.fillPoly(mask, [points], color=255)
Image.fromarray(mask)
六、深入步骤 6:端到端融合工程
把两个模型缝成一条流水线:大图进 → 定位每个印刷标志 → 转正 → 判异打分。
课程实现采用类封装 + 双 ONNX 方案:OBB 和 Padim 都导出为 ONNX,统一用 onnxruntime 推理(PadimAd 明确使用 CPU provider,适合无显卡产线环境)。
6.1 YoloObb 类:旋转检测封装
import cv2
import numpy as np
import onnxruntime as ort
class YoloObb:
def __init__(self, onnx_model_path):
self.session = ort.InferenceSession(onnx_model_path)
self.input_name = self.session.get_inputs()[0].name
def preprocess(self, img, target_size=(1024, 1024)):
original_shape = img.shape[:2]
original_img = img.copy()
r = min(target_size[0] / original_shape[0],
target_size[1] / original_shape[1])
new_unpad = (int(original_shape[1] * r), int(original_shape[0] * r))
img_resized = cv2.resize(
img, new_unpad, interpolation=cv2.INTER_LINEAR)
dw = target_size[1] - new_unpad[0]
dh = target_size[0] - new_unpad[1]
top, bottom = dh // 2, dh - dh // 2
left, right = dw // 2, dw - dw // 2
img_padded = cv2.copyMakeBorder(
img_resized, top, bottom, left, right,
cv2.BORDER_CONSTANT, value=(114, 114, 114),
)
img_data = img_padded.astype(np.float32) / 255.0
img_data = np.transpose(img_data, (2, 0, 1)) # CHW
img_data = np.expand_dims(img_data, axis=0) # NCHW
return img_data, original_img, (top, left), original_shape, r
def __call__(self, img):
img_data, original_img, pad_info, original_shape, r = self.preprocess(img)
outputs = self.session.run(None, {self.input_name: img_data})
results = self.postprocess(outputs, pad_info, r, conf_thres=0.25)
return results
postprocess 即 3.7 节的解码函数,直接搬进来作为类方法即可。
6.2 PadimAd 类:异常检测封装(CPU 版)
class PadimAd:
def __init__(self, onnx_model_path):
self.sess = ort.InferenceSession(
onnx_model_path, providers=["CPUExecutionProvider"] # 产线无显卡
)
self.input_name = self.sess.get_inputs()[0].name
self.output_names = [o.name for o in self.sess.get_outputs()]
def preprocess(self, img) -> np.ndarray:
# 预处理图像:读取、缩放、归一化、转 CHW 并增加 batch 维度
img = cv2.resize(img, (self.image_size, self.image_size)) # 直接缩放,无 letterbox
img = np.asarray(img, dtype=np.float32) / 255.0 # 归一化到 [0,1]
img = img.transpose(2, 0, 1) # HWC -> CHW
img = img[None, ...] # 增加 batch 维度
return img
def __call__(self, img):
x = self.preprocess(img)
outputs = self.sess.run(self.output_names, {self.input_name: x})
return outputs
与 OBB 预处理的两点不同:Padim 输入直接 resize 到训练 image_size(如 256),不做 letterbox;推理设备指定 CPUExecutionProvider。
6.3 端到端主流程
if __name__ == "__main__":
obb_model_path = "runs/obb/train2/weights/best.onnx"
yolo_obb = YoloObb(obb_model_path)
# Anomalib 1.x 训练 Padim 后 engine.export() 导出的 ONNX 路径
ad_model_path = "results/Padim/MVTecAD/glasslogo/latest/weights/onnx/model.onnx"
ad_model = PadimAd(ad_model_path)
for img_path in glob.glob("datasets/labelme/glass_logo/*.jpg"):
img = cv2.imread(img_path)
results = yolo_obb(img) # ① 定位所有印刷标志
if len(results) >= 1:
res = results[0] # 取第一个(单标识场景)
cx, cy = res["cx"], res["cy"]
w, h = res["w"], res["h"]
angle = res["angle_deg"]
rect_img = rotate_and_crop_rect( # ② 转正裁剪
img, (cx, cy), w, h, angle, padding=10)
outputs = ad_model(rect_img) # ③ 异常检测
print(f"{os.path.basename(img_path)} 异常分数: {outputs[0][0, 0]}")
输出示例:2025-11-05T14-24-53-770484_NG.jpg 异常分数: 0.8731
6.4 扩展:缺陷坐标映射回原图【扩展,非课程内容】
课程版只打印图像级分数。若需在大图上标注"哪个标志的哪个位置有缺陷",需把热力图峰值点从转正小图逆映射回原图:
def rotate_and_crop_rect_v2(img, center, w, h, angle_deg, padding=10):
# 转正裁剪,额外返回 (M_inv, roi_offset),用于缺陷坐标逆映射
# ... 与 rotate_and_crop_rect 前半完全相同 ...
M = cv2.getAffineTransform(src, dst)
rotated = cv2.warpAffine(crop, M, (int(w), int(h)), flags=cv2.INTER_LINEAR)
M_inv = cv2.invertAffineTransform(M) # 小图坐标 → crop 坐标
return rotated, M_inv, (x1, y1)
# 映射原理:
# 原图 ←加偏移(x1,y1)← crop 图 ←M⁻¹← 缺陷点(dx, dy)
# 缺陷点取 anomaly_map 的 argmax;仿射矩阵必然可逆,映射严格无损
注意:ad_model(rect_img)返回的是 ONNX 原始输出,取outputs[0][0, 0]为图像级异常分数;像素级热力图需取对应输出头,具体以self.sess.get_outputs()的 shape 为准。
七、常见报错速查
|
问题 |
原因 |
解决 |
|---|---|---|
|
|
OpenCV 不支持中文路径 |
|
|
|
返回 |
|
|
ONNX 推理结果全错/偏移 |
预处理尺寸 ≠ 训练 imgsz |
统一为 1024(本文) |
|
旋转框"打结"/显示错乱 |
四点顺序不一致 |
统一用 |
|
|
0.x |
用 1.x |
|
类别全错(精度莫名低) |
类别索引顺序变了 |
核对 |
|
|
版本过旧 |
|
八、总结
┌─────────────────────────────────────────────────────┐
│ 步骤1 labelme 标注 → OBB 数据集转换 │
│ (minAreaRect 最小外接旋转矩形) │
│ 步骤2 YOLO11-OBB 训练(imgsz=1024)→ 导出 ONNX │
│ 步骤3 纯 ONNX Runtime 部署 │
│ (letterbox + NMSBoxesRotated 解码) │
├─────────────────────────────────────────────────────┤
│ 步骤4 批量检测 → 仿射转正裁剪 → 方向一致的小图数据集 │
├─────────────────────────────────────────────────────┤
│ 步骤5 Anomalib Padim 只学正常 → 导出 ONNX(CPU 可跑)│
├─────────────────────────────────────────────────────┤
│ 步骤6 端到端:大图 → 定位 → 转正 → 判异 → 打分 │
└─────────────────────────────────────────────────────┘
两个模型各司其职:OBB 解决"在哪、歪多少",Padim 解决"正了之后好不好"。
这就是工业质检机器视觉的完整入门闭环——标注、训练、部署、数据标准化、异常检测、产线融合,每一步都有可直接复用的代码。
说明:文中标注【补全】的片段(data.yaml 生成细节、训练超参、Anomalib 训练段)为依据截图上下文与官方 API 规范整理补充;标注【扩展】的片段(缺陷坐标逆映射)为整理者增强,课程内容只到异常打分。复现时如遇 API 差异以所用库版本文档为准。
浙公网安备 33010602011771号