PLC高级实战:印刷标志与工业质检异常检测完整流程(下半部份)

工业质检必修的机器视觉入门课程 —— 从数据标注到产线部署,一套代码打通"旋转目标检测 + 异常检测"双模型流水线。
技术栈:ultralytics(YOLO11-OBB) + onnxruntime + anomalib>=1.0 + OpenCV
全文代码基于课程实录整理,标注【补全】/【扩展】的片段为整理者按工程规范补充,复现时如遇 API 差异以所用库版本文档为准。

一、我们的目的:做什么例子

工业现场有一块玻璃,上面印着印刷标志(logo)。产线要求机器自动回答三个问题:

  1. 标志在哪? —— 印刷位置可能偏移,且玻璃上每个标志的安装角度不一致
  2. 歪了多少? —— 必须知道旋转角度,才能把标志"转正"成统一方向
  3. 印得好不好? —— 转正后的标志有没有缺印、划痕、颜色异常等缺陷

靠人工目检?慢、累、标准不统一。本课程用两个深度学习模型分工解决:

模型

职责

技术

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 为准。

七、常见报错速查

问题

原因

解决

cv2.imread 中文路径返回 None

OpenCV 不支持中文路径

np.fromfile + cv2.imdecode

NMSBoxesRotated 后 int(i) 报错

返回 [[i]] 嵌套结构

np.array(indices).flatten()

ONNX 推理结果全错/偏移

预处理尺寸 ≠ 训练 imgsz

统一为 1024(本文)

旋转框"打结"/显示错乱

四点顺序不一致

统一用 cv2.boxPoints 输出,勿手拼

anomalib 旧教程代码跑不通

0.x Inferencer API 已废弃

用 1.x Engine.predict(model=, data_path=)

类别全错(精度莫名低)

类别索引顺序变了

核对 data.yaml names 与旧权重一致性

ultralytics.checks() 报错

版本过旧

pip install -U ultralytics

八、总结

┌─────────────────────────────────────────────────────┐
│  步骤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 差异以所用库版本文档为准。
posted @ 2026-09-14 14:46  鬼门元歌  阅读(8)  评论(0)    收藏  举报