工业视觉实战:从标注数据到格式转换到工业质检基础,yolo识别编

摘要 :本文完整记录一个工业视觉项目的全链路实现:用训练好的 YOLOv8-seg ONNX 模型对原始图片自动预标注 ,经 LabelMe 人工修正后,通过自研转换器批量转为 YOLO 分割格式,可视化质检后进入训练;训练好的模型最终封装进端到端流水线------批量处理 PDF 古籍文档,完成印章检测、文字分割与像素级染色还原。全文贯穿「模型即服务」的工程思想,并汇总了 OpenCV 中文路径、宽高顺序、letterbox 逆变换等高频踩坑点。

关键词:YOLOv8-seg;实例分割;ONNX Runtime;LabelMe;数据格式转换;YOLO 数据预处理;工业视觉;古籍文档处理

目录

  • 一、项目背景与整体流程
  • 二、数据预标注:让模型先帮人工"打草稿"
  • [2.1 WordSeg:ONNX 推理封装类](#2.1 WordSeg:ONNX 推理封装类)
  • [2.2 preprocess:letterbox 五步预处理](#2.2 preprocess:letterbox 五步预处理)
  • [2.3 postprocess:YOLOv8-seg 标准解码](#2.3 postprocess:YOLOv8-seg 标准解码)
  • [2.4 process_mask:掩膜系数合成与缩放](#2.4 process_mask:掩膜系数合成与缩放)
  • [2.5 掩膜转多边形:轮廓提取](#2.5 掩膜转多边形:轮廓提取)
  • [2.6 生成 LabelMe JSON](#2.6 生成 LabelMe JSON)
  • [2.7 预标注入口:批量处理](#2.7 预标注入口:批量处理)
  • [三、格式转换:LabelMe JSON → YOLO 分割格式](#三、格式转换:LabelMe JSON → YOLO 分割格式)
  • [3.1 两种格式对比](#3.1 两种格式对比)
  • [3.2 转换器整体结构](#3.2 转换器整体结构)
  • [3.3 主流程七步法](#3.3 主流程七步法)
  • [3.4 单文件转换的防御式设计](#3.4 单文件转换的防御式设计)
  • [3.5 坐标处理三件套](#3.5 坐标处理三件套)
  • [3.6 写入标签的最终格式](#3.6 写入标签的最终格式)
  • [3.7 自动生成 data.yaml](#3.7 自动生成 data.yaml)
  • [3.8 入口函数与配置管理](#3.8 入口函数与配置管理)
  • 四、标签可视化:转换结果质检
  • 五、端到端实战:古籍印章检测与文字分割染色
  • [5.1 双模型架构](#5.1 双模型架构)
  • [5.2 PDF 逐页渲染与图像预处理](#5.2 PDF 逐页渲染与图像预处理)
  • [5.3 印章检测与上下半页分治](#5.3 印章检测与上下半页分治)
  • [5.4 印章四分类业务逻辑](#5.4 印章四分类业务逻辑)
  • [5.5 ROI 级联:印章区域内文字分割与染色](#5.5 ROI 级联:印章区域内文字分割与染色)
  • [5.6 结果输出](#5.6 结果输出)
  • 六、踩坑总结(必读)
  • 七、总结

一、项目背景与整体流程

工业视觉项目的落地从来不是"训出一个模型"就结束,而是一条完整的工程流水线。本文项目的业务对象是古籍文档数字化:从扫描 PDF 中检测印章、分割印章内的文字笔画,并将黑白墨迹"还原"为彩色,辅助后续的研究与出版。

整条流水线分为四大环节,环环相扣:

javascript 复制代码
┌─────────────────────────────────────────────────────────────────┐
│ 环节一:预标注(第二章)                                          │
│   原始图片 ──YOLOv8-seg(ONNX)──→ 自动多边形 ──→ LabelMe JSON      │
│                        (模型先打草稿,人工只需修错)               │
├─────────────────────────────────────────────────────────────────┤
│ 环节二:格式转换(第三章)                                         │
│   LabelMe JSON ──LabelMeToYOLOConverter──→ YOLO 分割数据集        │
│                        (自动划分训练/验证集 + 生成 data.yaml)     │
├─────────────────────────────────────────────────────────────────┤
│ 环节三:质检(第四章)                                            │
│   YOLO 标签 ──PIL 绘制──→ 可视化核对(防止"垃圾进垃圾出")           │
├─────────────────────────────────────────────────────────────────┤
│ 环节四:端到端部署(第五章)                                       │
│   PDF ──渲染──→ 印章检测(SealDetector) ──→ ROI 文字分割(WordSeg)    │
│        ──→ 像素级染色 ──→ 彩色结果图 + record.json               │
└─────────────────────────────────────────────────────────────────┘

其中贯穿全文的核心思想是**「模型即服务,一次封装多处复用」**:预标注用的 WordSeg 分割模型,标注数据训练迭代后,在端到端流程中原封不动地复用同一套推理封装。下文逐一展开。


二、数据预标注:让模型先帮人工"打草稿"

标注是视觉项目最耗时的一环。我们的策略是:用已有模型对原始图片自动预标注,生成 LabelMe JSON,标注员只需在 LabelMe 软件中修正错误。实践下来可节省 50%~80% 的标注时间。

预标注脚本共四个核心组件:WordSeg(ONNX 推理封装)、label2me(格式组装)、resize_img(尺寸归一)、gen_label(单图全流程),入口 main() 批量调度。

2.1 WordSeg:ONNX 推理封装类

预标注模型是 YOLOv8-seg 导出的 ONNX 文件,通过 ONNX Runtime 推理,不依赖 PyTorch/Ultralytics,部署极其轻量:

python 复制代码
word_seg_model_path = "models/seg_word12.onnx"
word_seg_model = WordSeg(word_seg_model_path)

__init__ 中的三行代码完成三种自动适配,值得逐行品味:

python 复制代码
class WordSeg:
    def __init__(self, onnx_model):
        # ① 自动选择 GPU / CPU
        self.session = ort.InferenceSession(
            onnx_model,
            providers=(
                ["CUDAExecutionProvider", "CPUExecutionProvider"]
                if ort.get_device() == "GPU"
                else ["CPUExecutionProvider"]
            ),
        )
        # ② 自动适配 fp16 / fp32
        self.ndtype = (
            np.half
            if self.session.get_inputs()[0].type == "tensor(float16)"
            else np.single
        )
        # ③ 从模型输入签名读取要求尺寸,不写死
        self.model_height, self.model_width = [
            x.shape for x in self.session.get_inputs()
        ][0][-2:]
适配点 写法 收益
推理设备 providers=[CUDA, CPU] if GPU else [CPU] 一份代码两种环境
数据精度 np.half if tensor(float16) else np.single 避免类型不匹配报错
输入尺寸 get_inputs()[0].shape[-2:] 换模型不用改代码

推理入口的参数暴露了模型的"身份":

python 复制代码
def __call__(self, im0, conf_threshold=0.4, iou_threshold=0.45, nm=32):
    im, ratio, (pad_w, pad_h) = self.preprocess(im0)
    preds = self.session.run(None, {self.session.get_inputs()[0].name: im})
    return self.postprocess(preds, im0=im0, ratio=ratio, pad_w=pad_w, pad_h=pad_h,
                            conf_threshold=conf_threshold,
                            iou_threshold=iou_threshold, nm=nm)
  • session.run(None, ...):第一个参数 None 表示返回全部输出(YOLOv8-seg 有两个输出:检测头 + 原型掩膜 protos)
  • nm=32掩膜系数维度,是 YOLOv8-seg 的标志性参数
  • 预标注场景 conf_threshold=0.4宁可漏标,也不错标------漏标人工补画很快,错标修改反而更费眼神

2.2 preprocess:letterbox 五步预处理

模型输入要求固定尺寸(如 640×640),直接拉伸会变形,YOLO 系列采用 letterbox(等比缩放 + 灰边填充):

python 复制代码
def preprocess(self, img):
    shape = img.shape[:2]                              # 原图 (h, w)
    new_shape = (self.model_height, self.model_width)

    # ① 等比缩放比例:取宽、高缩放比中较小者,保证完整放入
    r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
    ratio = r, r
    new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))

    # ② 两侧居中填充量
    pad_w, pad_h = (new_shape[1] - new_unpad[0]) / 2, (new_shape[0] - new_unpad[1]) / 2

    # ③ 缩放(尺寸变了才缩)
    if shape[::-1] != new_unpad:
        img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)

    # ④ 灰边填充至正方形,填充色 114(与训练时一致,避免分布偏移)
    top, bottom = int(round(pad_h - 0.1)), int(round(pad_h + 0.1))
    left, right = int(round(pad_w - 0.1)), int(round(pad_w + 0.1))
    img = cv2.copyMakeBorder(img, top, bottom, left, right,
                             cv2.BORDER_CONSTANT, value=(114, 114, 114))

    # ⑤ HWC→CHW、BGR→RGB、归一化、加 batch 维
    img = np.ascontiguousarray(np.einsum("HWC->CHW", img)[::-1],
                               dtype=self.ndtype) / 255.0
    img_process = img[None] if len(img.shape) == 3 else img
    return img_process, ratio, (pad_w, pad_h)

本段最精妙的一行

python 复制代码
np.einsum("HWC->CHW", img)[::-1]

einsum 完成 HWC→CHW 换轴后,[::-1] 在通道轴上倒序,一行代码同时完成换轴和 BGR→RGB 转换 (OpenCV 读入是 BGR,模型训练用的是 RGB)。最后 [None] 增加 batch 维度,张量形状 (1, 3, H, W) 送入 ONNX。

为什么填充色必须是 114? 训练阶段用的也是同样的 letterbox 填充,推理与训练保持一致才能避免输入分布偏移。

2.3 postprocess:YOLOv8-seg 标准解码

python 复制代码
def postprocess(self, preds, im0, ratio, pad_w, pad_h, conf_threshold, iou_threshold, nm=32):
    x, protos = preds[0], preds[1]                       # 检测头 + 原型掩膜
    x = np.einsum("bcn->bnc", x)                         # 转置为 (batch, 候选框, 特征)

    # ① 置信度过滤:4:-nm 区间是类别分数(前4个是框坐标,后nm个是掩膜系数)
    x = x[np.amax(x[..., 4:-nm], axis=-1) > conf_threshold]

    # ② 拼接:框 + 最大类别置信度 + 类别id + 掩膜系数
    x = np.c_[x[..., :4],
              np.amax(x[..., 4:-nm], axis=-1),
              np.argmax(x[..., 4:-nm], axis=-1),
              x[..., -nm:]]

    # ③ NMS 非极大值抑制
    x = x[cv2.dnn.NMSBoxes(x[:, :4], x[:, 4], conf_threshold, iou_threshold)]
    if len(x) == 0:
        return None, []

    # ④ xywh → xyxy(中心点格式转角点格式)
    x[..., [0, 1]] -= x[..., [2, 3]] / 2
    x[..., [2, 3]] += x[..., [0, 1]]

    # ⑤ 坐标还原:去 padding → 除以缩放比 → 裁剪到原图边界
    x[..., :4] -= (pad_w, pad_h, pad_w, pad_h)
    x[..., :4] /= min(ratio)
    x[..., [0, 2]] = x[:, [0, 2]].clip(0, im0.shape[1])   # x 界
    x[..., [1, 3]] = x[:, [1, 3]].clip(0, im0.shape[0])   # y 界

    return self.process_mask(protos[0], x[:, 6:], x[:, :4], im0.shape)

坐标还原三步序(preprocess 的逆过程):去 pad → 除 ratio → clip,顺序不能乱 。这正是 2.2 节预处理必须返回 (ratio, pad_w, pad_h) 的原因------后处理要靠它们把模型坐标系"翻译"回原图坐标系。

2.4 process_mask:掩膜系数合成与缩放

YOLOv8-seg 的实例掩膜不是直接输出的,而是检测头给出的 32 维系数对 32 张原型掩膜(protos)做线性组合

python 复制代码
def process_mask(self, protos, masks_in, bboxes, im0_shape):
    c, mh, mw = protos.shape                              # protos: (32, mh, mw)
    masks = (
        np.matmul(masks_in, protos.reshape(c, -1))        # (N,32) @ (32, H×W)
        .reshape(-1, mh, mw)                              # 还原空间形状 (N, mh, mw)
        .transpose(1, 2, 0)                               # → (mh, mw, N) = HWN
    )
    masks = np.ascontiguousarray(masks)
    mask = self.scale_mask(masks, im0_shape)              # 缩放回原图尺寸
    return mask, bboxes
python 复制代码
@staticmethod
def scale_mask(masks, im0_shape, ratio_pad=None):
    im1_shape = masks.shape[:2]
    if ratio_pad is None:                                 # 从原图尺寸反推 letterbox 参数
        gain = min(im1_shape[0] / im0_shape[0], im1_shape[1] / im0_shape[1])
        pad = (im1_shape[1] - im0_shape[1] * gain) / 2, (im1_shape[0] - im0_shape[0] * gain) / 2
    else:
        pad = ratio_pad[1]
    top, left = int(round(pad[1] - 0.1)), int(round(pad[0] - 0.1))
    bottom, right = int(round(im1_shape[0] - pad[0] + 0.1)), int(round(im1_shape[1] - pad[0] + 0.1))
    masks = masks[top:bottom, left:right]                 # 裁掉灰边

    masks = np.greater(masks, 0.5)                        # 二值化(sigmoid 后 >0.5 为前景)
    masks = np.einsum("HWN -> NHW", masks)                # 换轴
    mask = np.any(masks, axis=0).astype(np.uint8) * 255   # 多实例合并为单图
    mask = cv2.resize(mask, (im0_shape[1], im0_shape[0]), interpolation=cv2.INTER_LINEAR)
    return mask

要点:scale_mask 不依赖 self,用 @staticmethod 封装为纯函数;gain 与预处理阶段的缩放系数互逆,配合 pad 裁剪精确去除灰边------letterbox 的正逆变换必须严格对称,否则掩膜会整体偏移。

2.5 掩膜转多边形:轮廓提取

模型输出的是像素掩膜,LabelMe 需要多边形顶点,用 OpenCV 轮廓算法桥接:

python 复制代码
def gen_points(infer_image):
    mask, boxes = word_seg_model(infer_image)             # __call__ 返回 (mask, boxes)
    points_list = []
    for box in boxes:
        x1, y1, x2, y2 = [round(v) for v in box]
        box_mask = mask[y1:y2, x1:x2]                     # 按框裁剪局部掩膜

        c = cv2.findContours(box_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)[0]
        if not c:
            c = [np.zeros((0, 2))]                        # 无轮廓兜底
        contour = c[np.array([len(x) for x in c]).argmax()]  # 取最大轮廓,防噪声

        # 多边形近似:epsilon = 0.002 × 周长,压缩顶点数
        epsilon = 0.002 * cv2.arcLength(contour, True)
        contour = cv2.approxPolyDP(contour, epsilon, True)

        c = np.array(contour).reshape(-1, 2) + [x1, y1]   # 局部坐标 + 框偏移 = 全图坐标
        points_list.append(c.tolist())
    return points_list

三个细节:

  • RETR_EXTERNAL 只取外轮廓(忽略孔洞),并取最大轮廓防噪声
  • approxPolyDP 多边形近似,epsilon=0.002×周长 是经验值------越小顶点越多越精确
  • 无分割结果时返回空多边形占位,保证下游拿到的列表结构一致不报错(防御式编程)

2.6 生成 LabelMe JSON

python 复制代码
def label2me(label_list):
    res = {"version": "5.5.0", "flags": {}, "shapes": []}
    for label in label_list:
        res["shapes"].append({
            "label": "word",                # 类别名(单类别场景硬编码)
            "points": label,                # 多边形顶点
            "shape_type": "polygon",
            "tags": {},
        })
    return res

LabelMe JSON 的关键字段 imageData------图像以 base64 内嵌,单文件即可在任何机器打开,无需图片:

python 复制代码
encode_image = cv2.imencode(".png", scrimg)[1]   # 内存中编码,不落盘
byte_data = encode_image.tobytes()
base64_str = base64.b64encode(byte_data).decode("ascii")
res["imageData"] = base64_str
res["imagePath"] = ""
res = json.dumps(res, ensure_ascii=False, indent=2)   # 中文不转义 + 格式化

cv2.imencode 在内存中完成编码,比"先 imwrite 到磁盘再读字节"高效得多;ensure_ascii=False 让中文标签保持可读;indent=2 方便人工核查和 git diff。

2.7 预标注入口:批量处理

python 复制代码
def main():
    img_list = glob.glob("datasets/rawdata/word/*.png")
    save_dir = "datasets/rawdata/preanno_word"
    os.makedirs(save_dir, exist_ok=True)
    for img_path in tqdm(img_list):
        gen_label(img_path, save_dir)

单图处理 gen_label 的前置流程还有两个实用技巧:

python 复制代码
def gen_label(img_f, save_dir):
    scrimg = cv2.imread(img_f)
    h, w = scrimg.shape[:2]
    if h * w < 2000:                          # ① 小图过滤:总像素数阈值
        print(img_f, "too small, skip")
        return
    scrimg = resize_img(scrimg)[0]            # ② 长边缩到 1024,返回 (图, 比例, pad)

def resize_img(img, max_size=1024):
    h, w = img.shape[:2]
    if h > w:
        ratio, new_h, new_w = max_size / h, max_size, round(w * max_size / h)
    else:
        ratio, new_w, new_h = max_size / w, max_size, round(h * max_size / w)
    return cv2.resize(img, (new_w, new_h)), ratio, (new_w - w) // 2, (new_h - h) // 2

resize_img 返回四元组 (图, 缩放比例, pad_x, pad_y),缩放信息全部保留,供坐标还原使用------预处理阶段留好"返程票",后处理才能回家

至此,人工打开 LabelMe 加载 JSON 即可在预标注基础上快速修正,产出正式数据集。


三、格式转换:LabelMe JSON → YOLO 分割格式

Ultralytics 训练要求特定目录结构 + 标签格式,手写一个 LabelMeToYOLOConverter 类完成批量转换、数据集划分和配置生成。

3.1 两种格式对比

LabelMe JSON (每张图一个同名 .json):

json 复制代码
{
  "imagePath": "xxx.jpg",
  "shapes": [
    {"label": "word", "shape_type": "polygon", "points": [[x1,y1], [x2,y2], ...]}
  ]
}

YOLO 分割标签 (每张图一个同名 .txt,每行一个目标):

javascript 复制代码
<class_id> <x1> <y1> <x2> <y2> ... <xn> <yn>
  • 坐标全部归一化到 0, 1(除以图像宽/高)
  • 与检测格式(每行固定 5 个值)不同,分割格式保留全部多边形顶点------本文转换器输出的是分割格式,可同时支持检测和分割训练

3.2 转换器整体结构

javascript 复制代码
LabelMeToYOLOConverter
├── __init__(input_dir, output_dir, train_ratio=0.8, random_seed=42)
├── _read_image_with_chinese_path()      # 中文路径读图(重点坑,见 3.4)
├── _clip_coordinate_to_boundary()       # 坐标越界裁剪
├── _normalize_and_clip_points()         # 归一化 + 裁剪 + 越界告警
├── _rectangle_to_polygon_points()       # 矩形统一转多边形
├── _extract_classes_from_annotations()  # 提取类别(字母序分配 id)
├── _create_output_directories()         # 创建 YOLO 目录结构
├── _split_dataset_files()               # 训练/验证集划分
├── _find_image_file()                   # 按同名多扩展名找图
├── _convert_single_annotation()         # 单文件转换(防御式设计)
├── _generate_yolo_config_file()         # 生成 data.yaml
├── _print_conversion_statistics()       # 转换统计
└── convert()                            # 主入口(七步法)

输出目录结构(YOLO 标准布局):

javascript 复制代码
output_dir/
├── images/train/   images/val/
├── labels/train/   labels/val/
└── data.yaml

3.3 主流程七步法

python 复制代码
def convert(self):
    # 步骤1:提取类别 → classes_dict(label → class_id)
    self._extract_classes_from_annotations()
    if not self.classes_dict:
        print("错误:未找到任何有效的类别"); return False

    # 步骤2:创建输出目录
    self._create_output_directories()

    # 步骤3:glob 所有 .json,按 8:2 划分训练/验证集
    json_files = glob.glob(str(self.input_dir / "*.json"))
    train_files, val_files = self._split_dataset_files(json_files)

    # 步骤4/5:批量转换(tqdm 进度条)
    for json_file in tqdm(train_files, desc="转换训练集"):
        if self._convert_single_annotation(json_file, out/"labels"/"train", out/"images"/"train"):
            train_success_count += 1
    # 验证集同理...

    # 步骤6:生成 data.yaml
    self._generate_yolo_config_file()

    # 步骤7:打印统计
    self._print_conversion_statistics(...)
    return True

训练/验证集划分:

python 复制代码
def _split_dataset_files(self, json_files):
    shuffled_files = json_files.copy()      # 先拷贝再打乱,不污染原列表
    random.shuffle(shuffled_files)          # random.seed(42) 已在 __init__ 固定
    split_index = int(len(shuffled_files) * self.train_ratio)
    return shuffled_files[:split_index], shuffled_files[split_index:]

random.seed(42) 在初始化时设置,保证每次划分完全可复现------这是实验可复现性的基础。

3.4 单文件转换的防御式设计

_convert_single_annotation 体现了"问题可见化、异常不扩散"的设计哲学:

python 复制代码
def _convert_single_annotation(self, json_path, output_label_dir, output_image_dir):
    try:
        with open(json_path, "r", encoding="utf-8") as file:
            annotation_data = json.load(file)

        image_path = self._find_image_file(json_path)
        if not image_path:
            return False                       # 找不到图 → 直接返回

        image = self._read_image_with_chinese_path(image_path)
        if image is None:
            return False                       # 读图失败 → 直接返回(Early Return)

        image_height, image_width = image.shape[:2]   # ⚠️ OpenCV 顺序:(H, W, C)
        txt_path = output_label_dir / (Path(json_path).stem + ".txt")
        ...
    except Exception as e:
        print(f"转换文件 {json_path} 时出错:{e}")
        return False                           # 单文件失败不中断批处理

单文件转换状态机:

检查点 失败行为
JSON 解析异常 外层 try/except 捕获,返回 False
找不到同名图像 return False
图像读取失败 return False
标签不在类别表 跳过该 shape(不失败)
polygon 点数 < 3 跳过该 shape(不失败)

⭐ 高频坑一:OpenCV 中文路径

cv2.imread() 不支持中文/空格路径,会静默返回 None(不报错!)。解决方案:

python 复制代码
def _read_image_with_chinese_path(self, image_path):
    try:
        raw_data = np.fromfile(image_path, dtype=np.uint8)     # numpy 读字节流
        image = cv2.imdecode(raw_data, cv2.IMREAD_COLOR)       # 内存解码
        return image
    except Exception as e:
        print(f"读取图像失败 {image_path}: {e}")
        return None

⭐ 高频坑二:image.shape 的宽高顺序

OpenCV 读入的图像 shape 是 (高H, 宽W, 通道C)

python 复制代码
image_height, image_width = image.shape[:2]   # 先高后宽!

写反了会导致归一化时宽高互换,标签全部错误且极难排查(不会报错,只是框的位置全乱)。

⭐ 高频坑三:PIL 与 OpenCV 顺序相反(第四章质检会用到)

尺寸获取 返回顺序
OpenCV image.shape[:2] (H, W)
PIL img.size (W, H)

两套顺序正好相反,跨库混用必然坐标错位。

其他防御细节

python 复制代码
# 多扩展名查找同名图像
image_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"]

# 兼容 LabelMe 旧版无 shape_type 字段的 JSON
shape_type = shape.get("shape_type", "polygon")

# 标签去空格,防止 "cat " 和 "cat" 被当成两类
label = shape.get("label", "").strip()

# 排除 LabelMe 中标记废图用的 bad 标签
if label and label != "bad":
    classes_set.add(label)

3.5 坐标处理三件套

① 越界裁剪(clamp 经典写法)

python 复制代码
def _clip_coordinate_to_boundary(self, coordinate, max_value):
    return max(0, min(coordinate, max_value - 1))   # 限制在 [0, max-1]

② 归一化 + 越界告警

python 复制代码
def _normalize_and_clip_points(self, points, image_width, image_height):
    normalized_points = []
    adjusted_points_count = 0
    for x, y in points:
        x_clipped = self._clip_coordinate_to_boundary(x, image_width)   # 先裁剪(像素域)
        y_clipped = self._clip_coordinate_to_boundary(y, image_height)
        x_norm, y_norm = x_clipped / image_width, y_clipped / image_height  # 再归一化
        normalized_points.extend([x_norm, y_norm])                      # 扁平存储
        if x != x_clipped or y != y_clipped:
            adjusted_points_count += 1
    if adjusted_points_count > 0:
        print(f"警告:{adjusted_points_count} 个坐标点被调整到图像边界内")
    return normalized_points

顺序不能反:先裁剪(像素域)再归一化 。越界告警让标注质量问题可见 而不是悄悄吞掉------很好的工程习惯。extend 展开存储,输出 [x1,y1,x2,y2,...] 扁平列表,这正是 YOLO 分割标签的格式。

③ 矩形统一转多边形(归一化输入、统一处理路径):

python 复制代码
def _rectangle_to_polygon_points(self, rectangle_points, image_width, image_height):
    x1, y1 = rectangle_points[0]
    x2, y2 = rectangle_points[1]
    polygon_points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]   # 顺时针:左上→右上→右下→左下
    return self._normalize_and_clip_points(polygon_points, image_width, image_height)

LabelMe 的 rectangle 只有 2 个对角点,先转成 4 顶点顺时针多边形,之后所有 shape 走同一条 polygon 管线,下游只需一份逻辑。

3.6 写入标签的最终格式

python 复制代码
for shape in annotation_data["shapes"]:
    label = shape["label"]
    if label not in self.classes_dict:
        continue
    label_index = self.classes_dict[label]
    points = shape["points"]
    shape_type = shape.get("shape_type", "polygon")

    if shape_type == "polygon":
        if len(points) < 3: continue                       # 多边形至少3个点
        normalized_points = self._normalize_and_clip_points(points, image_width, image_height)
    elif shape_type == "rectangle":
        if len(points) != 2: continue                      # 矩形需要2个点
        normalized_points = self._rectangle_to_polygon_points(points, image_width, image_height)
    else:
        print(f"不支持的形状类型:{shape_type}"); continue    # 问题可见化

    if len(normalized_points) < 6:                         # 3点×2坐标
        continue

    points_str = " ".join(f"{coord:.6f}" for coord in normalized_points)  # 6位小数
    txt_file.write(f"{label_index} {points_str}\n")

shutil.copy2(image_path, output_image_dir)                 # 复制图像并保留元数据

坐标保留 6 位小数是精度与文件体积的平衡;shutil.copy2copy 多保留修改时间等元数据。

3.7 自动生成 data.yaml

类别提取 + id 分配(字母序保证可复现):

python 复制代码
classes_set = set()
for json_file in tqdm(json_files, desc="提取类别信息"):
    try:
        with open(json_file, "r", encoding="utf-8") as file:
            annotation_data = json.load(file)
        for shape in annotation_data.get("shapes", []):
            label = shape.get("label", "").strip()
            if label and label != "bad":
                classes_set.add(label)
    except Exception:
        pass

sorted_classes = sorted(list(classes_set))                 # 按字母序排序确保一致性
self.classes_dict = {name: idx for idx, name in enumerate(sorted_classes)}

配置文件生成(sorted 按 id 排序输出,names 用字典格式):

python 复制代码
config_content = f"""# YOLO数据集配置文件
# 自动生成 by LabelMeToYOLOConverter

path: {os.path.abspath(self.output_dir)}
train: images/train
val: images/val
test: 

nc: {len(self.classes_dict)}

names:
"""
for class_name, class_id in sorted(self.classes_dict.items(), key=lambda x: x[1]):
    config_content += f"  {class_id}: {class_name}\n"

config_file_path = self.output_dir / "data.yaml"
with open(config_file_path, "w", encoding="utf-8") as file:
    file.write(config_content)

两个细节:

  • pathos.path.abspath()绝对路径,避免训练时因工作目录不同而找不到数据
  • names 用字典格式 {0: cat, 1: dog} 而非列表------对乱序/非连续 id 更安全,且可视化反查时可直接用 id 索引

3.8 入口函数与配置管理

python 复制代码
CONFIG = {
    "input_dir": "./datasets/melabel/word",   # LabelMe 数据目录
    "output_dir": "./datasets/yolo_word",     # YOLO 输出目录
    "train_ratio": 0.8,
    "random_seed": 42,
}

converter = LabelMeToYOLOConverter(**{k: CONFIG[k] for k in (...)}
success = converter.convert()
if not success:
    print("转换过程中出现错误,请检查输入数据和配置")
    return 1          # 返回码 1 = 失败,便于脚本化调用
return 0

路径与超参数集中在 CONFIG 字典,改配置不动业务代码;返回码 0/1 符合 CLI 惯例,方便接入自动化流水线。

转换完成后的统计输出:

javascript 复制代码
============================================================
转换完成!
============================================================
训练集: 156/160 (97.5%)
验证集: 39/40 (97.5%)
总体成功率: 97.6%
类别数量: 5
输出目录: /path/to/output
============================================================

四、标签可视化:转换结果质检

格式转换极易出错(宽高写反、归一化除错、类别映射乱),必须可视化抽检再进训练。用 PIL 把标签绘制回原图:

python 复制代码
from PIL import Image, ImageDraw, ImageFont
import yaml

with open("datasets/yolo_word/labels/train/000124.txt", "r") as f:
    label_txt = f.readlines()
img = Image.open("datasets/yolo_word/images/train/000124.png")
with open("datasets/yolo_word/data.yaml", "r", encoding="utf-8") as f:
    cfg = yaml.safe_load(f)

imgw, imgh = img.size                    # ⚠️ PIL 的 size 是 (宽, 高),与 OpenCV 相反!
font_text = ImageFont.truetype("chinese_cht.ttf", 30, encoding="utf-8")  # 中文字体必须显式加载
draw = ImageDraw.Draw(img)

for one_label in label_txt:
    label_id = one_label.strip().split(" ")[0]
    label = cfg["names"][int(label_id)]              # data.yaml 反查类别名
    points = one_label.strip().split(" ")[1:]

    points_list = []
    for i in range(0, len(points) // 2):             # 扁平列表成对解析
        x = int(float(points[i * 2]) * imgw)         # 归一化 → 像素(注意先 float!)
        y = int(float(points[i * 2 + 1]) * imgh)
        points_list.append((x, y))

    draw.polygon(points_list, outline="purple", width=3)   # 只画边框,便于检查贴合度

    xmin = min(p[0] for p in points_list)            # 多边形包围盒左上角
    ymin = min(p[1] for p in points_list)
    draw.text((xmin, ymin - 30), label, fill="red", font=font_text)  # 文字放目标上方30px,不遮挡

要点回顾:

  • txt 中读出的坐标是字符串 ,必须先 float() 再运算
  • 中文标签必须加载中文字体 (如 chinese_cht.ttf),否则显示方框 □□□
  • yaml.safe_load 读配置,比 yaml.load 安全
  • 文字画在目标上方 30 像素处,避免遮挡目标影响判断

五、端到端实战:古籍印章检测与文字分割染色

数据训练迭代后,进入最终交付环节:批量处理 PDF 古籍,检测印章、分割印章内文字,并将黑白墨迹"还原"为彩色。

5.1 双模型架构

python 复制代码
def main():
    seal_det_model = SealDetector("models/det_seal9.onnx")   # 印章检测(多分类)
    word_seg_model = WordSeg("models/seg_word12.onnx")       # 文字分割(即第二章预标注模型!)

再次体现「模型即服务」:WordSeg 与预标注用的是同一个模型文件、同一套封装------训练迭代后零改动复用。

5.2 PDF 逐页渲染与图像预处理

python 复制代码
pdfdoc = fitz.Document(pdf_path)                    # PyMuPDF 打开
for pg in tqdm(pdfdoc):
    page_number += 1
    if page_number < 6:                             # 跳过封面/目录/扉页
        continue
    pix = pg.get_pixmap(dpi=300)                    # 300dpi 高分辨率渲染(古籍小字必需)
    stream = pix.pil_tobytes(format="png")

    src_img = np.array(Image.open(io.BytesIO(stream)))   # 字节流 → 图(内存完成)
    if len(src_img.shape) == 3:                     # 彩色/灰度统一为灰度
        src_img = cv2.cvtColor(src_img, cv2.COLOR_BGR2GRAY)

    img = image_resize(src_img)                     # 尺寸归一 + 二值化
    infer_image = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB).astype(np.uint8)  # "黑白转彩色"

image_resize 把任意尺寸的页面统一到 3032×2148 附近,并做古籍图像的标准预处理------自适应二值化

python 复制代码
def image_resize(src_img):
    h, w = src_img.shape[:2]
    if h > 5000:                                    # 超大图等比缩小
        scale = min(h / 3032, w / 2148)
        img = cv2.resize(src_img, (round(w / scale), round(h / scale)))
    elif h < 2500:                                  # 小图统一放大
        img = cv2.resize(src_img, (2148, 3032))
    else:
        img = src_img
    img = np.where(img > 127, 255, 0).astype(np.uint8)   # 二值化:墨迹→0黑,纸张→255白
    return img

np.where(img > 127, 255, 0) 消除纸张泛黄、噪点等干扰。最后的 GRAY2RGB 把单通道灰度复制成三通道"伪彩色",满足模型输入------模型学的是形状/纹理特征,伪彩色不损失信息。

5.3 印章检测与上下半页分治

古籍页面细长,整页检测小印章容易漏检。策略:切成上下两个近方形区域分别推理,等效提升小目标检测精度:

python 复制代码
boxes = seal_det_model(infer_image)                  # 输出 (box, score, label)

up_record = {"imgname": f"{pdf_idx}_{page_number:04d}_up.png", "box": [], "seal_info": []}
down_record = {"imgname": f"{pdf_idx}_{page_number:04d}_down.png", "box": [], "seal_info": []}

up_img = infer_image[:resize_w, :]                   # 上半页
down_img = infer_image[resize_h - resize_w:, :]      # 下半页

按印章框中心点落在哪半页,分别进入 up/down 的处理分支(下半页坐标需做 seal_y0 - resize_h + resize_w 的平移变换)。

5.4 印章四分类业务逻辑

SealDetectorlabel(0~3)区分四种印章版式,各有不同处理:

label 印章类型 处理方式
0 小方印(模板 296×296) 按模板等比对齐,记录 scale 供还原
1 中方印(模板 740×732) 同上,用 seal1_shape
2 长条印 直接对框内暗像素染色
3 大尺寸方印 尺寸阈值过滤(w≥1350 且 h≥950),记录外扩四角框

以类 3 和类 0 为例:

python 复制代码
if label == 3:
    if w < 1350 or h < 950:                       # 尺寸不达标 → 跳过(防误检)
        continue
    up_record["box"] = [                          # 四角框,外扩 div=10 像素
        [seal_x0 - div, seal_y0 - div],
        [seal_x0 + w + div, seal_y0 - div],
        [seal_x0 + w + div, seal_y0 + h],
        [seal_x0 - div, seal_y0 + h],
    ]
elif label == 0:
    scale = (w / seal0_shape[0] + h / seal0_shape[1]) / 2   # 等比系数
    n_w, n_h = round(seal0_shape[0] * scale), round(seal0_shape[1] * scale)
    seal_x0 += (w - n_w) // 2                     # 居中修正
    seal_y0 += (h - n_h) // 2
    up_record["seal_info"].append([label, seal_x0, seal_y0, scale])

「黑白转彩色」的核心手法(类 2 长条印的直接染色):

python 复制代码
up_img[:, seal_x0:seal_x0 + w, 2] = np.where(
    up_img[:, seal_x0:seal_x0 + w, 2] < 127, 175,      # 暗像素的 B 通道设为 175
    up_img[:, seal_x0:seal_x0 + w, 2],
)

对印章区域像素做通道级染色------暗像素(<127)的 B 通道设为 175,黑色墨迹即呈现青灰色,实现"古色黑白转彩色"。

5.5 ROI 级联:印章区域内文字分割与染色

类 3 印章需要进一步分割框内文字再染色。精妙之处:WordSeg 只在印章 ROI 内推理,而不是整页------

python 复制代码
if len(up_record["box"]) > 0:
    x0, y0 = up_record["box"][0]
    x1, y1 = up_record["box"][2]
    x0, y0 = max(x0 - div, 0), max(y0 - div, 0)         # 二次外扩 + 边界保护
    x1, y1 = min(x1 + div, resize_w), min(y1 + div, resize_h)

    # ① 只在印章框内运行文字分割:省算力 + 避免正文误染色
    mask, word_boxes = word_seg_model(up_img[y0:y1, x0:x1])
    if mask is not None:
        # ② 掩膜与文字检测框求交,去除笔画粘连溢出
        mask = crop_mask(mask, word_boxes)
        # ③ 文字笔画像素染到 B 通道 = 175
        mask0 = np.where(mask > 127, 0, 255 - up_img[y0:y1, x0:x1, 0])
        up_img[y0:y1, x0:x1, 2] = np.where(mask0 > 127, 175,
                                          up_img[y0:y1, x0:x1, 2])

这个 ROI 级联设计一箭双雕:计算量降低约 90%;页面上正文文字不会被误染色,只有印章内文字被着色。

配套的 crop_mask------掩膜与检测框求交,去除分割常见的笔画溢出:

python 复制代码
def crop_mask(mask, boxes):
    zero_mask = np.zeros_like(mask)
    for box in boxes:
        x1, y1, x2, y2 = [round(v) for v in box]
        zero_mask[y1:y2, x1:x2] = 1
    return np.multiply(mask, zero_mask)     # 框外掩膜全部清零

5.6 结果输出

python 复制代码
record_list.append(up_record)
record_list.append(down_record)
cv2.imwrite(os.path.join(save_path, up_record["imgname"]), up_img)
cv2.imwrite(os.path.join(save_path, down_record["imgname"]), down_img)

with open(os.path.join(save_path, "record.json"), "w") as f:   # 每册一个汇总
    json.dump(record_list, f, indent=2)
pdfdoc.close()                                                  # 逐册释放句柄

输出结构:

javascript 复制代码
datasets/results/{册号}/
├── {册号}_{页码:04d}_up.png      # 染色后的上半页
├── {册号}_{页码:04d}_down.png    # 染色后的下半页
└── record.json                    # 整册印章元数据(box + seal_info)

页码 4 位补零 {page_number:04d} 保证文件名字典序与数值序一致;pdfdoc.close() 逐册释放句柄防内存泄漏。


六、踩坑总结(必读)

# 现象 解决方案
1 cv2.imread 中文路径 静默返回 None np.fromfile + cv2.imdecode
2 OpenCV 宽高顺序 归一化全错,无报错 h, w = image.shape[:2](先高后宽)
3 PIL/OpenCV 顺序相反 跨库坐标错位 PIL img.size(W, H)
4 letterbox 逆变换顺序 掩膜整体偏移 去 pad → 除 ratio → clip,严格对称
5 set 无序导致类别乱序 每次训练类别 id 不同 sorted()enumerate 分配
6 归一化前未裁剪越界坐标 训练报错/异常框 先 clip 到 [0, max-1] 再归一化
7 标签首尾空格 同类被拆成两类 .strip()
8 txt 坐标是字符串 直接运算报 TypeError float()
9 PIL 中文显示方框 □□□ ImageFont.truetype 加载中文字体
10 相对路径训练找不到数据 路径解析失败 data.yaml 的 path 用绝对路径

七、总结

本文以古籍文档数字化为业务载体,完整走通了工业视觉项目的四大环节:

  1. 预标注:YOLOv8-seg ONNX 模型自动产出 LabelMe JSON,人工只需修正,标注效率提升数倍。核心难点是 letterbox 的正逆变换与掩膜系数合成。
  2. 格式转换LabelMeToYOLOConverter 以防御式编程处理中文路径、越界坐标、脏标签等真实世界的脏数据,自动完成数据集划分与 data.yaml 生成。
  3. 可视化质检:"垃圾进垃圾出",转换结果必须回画图像人工核对。
  4. 端到端部署:双模型级联(印章检测 → ROI 文字分割),配合上下半页分治、四分类版式处理、像素级通道染色,产出"黑白转彩色"的数字化成果。

贯穿始终的工程思想值得反复体会:模型即服务,一次封装多处复用 (WordSeg 同时服务预标注与生产环境);问题可见化 (越界告警、不支持的类型打印、转换统计);可复现性(随机种子、字母序类别、排序后的文件列表)。


参考资料:Ultralytics YOLOv8 官方文档、ONNX Runtime 文档、LabelMe 项目、PyMuPDF 文档。文中代码为课堂项目整理版,仅供学习交流。

相关推荐
richard_yuu18 小时前
AOI 实战第五篇:c_broken 漏检严重,V4 迭代背后的权衡
开发语言·深度学习·yolo
richard_yuu1 天前
AOI 实战第六篇:.onnx 到 C++ 的最后一公里
c++·深度学习·yolo
彭祥.2 天前
基于DeepSeek智能体+YOLO+ResNet的饮食健康分析系统技术实现
yolo
论文复现现场2 天前
工业缺陷检测训练选 YOLO11 还是 RT-DETR?一张 24GB RTX 4090 跑通 PoC 的完整方案
yolo·计算机视觉·rtx4090
Είναι η κοπέλα2 天前
YOLO 演进 v5→v11 与 2026 选型指南
人工智能·yolo
hans汉斯2 天前
【计算机科学与应用】层级评论上下文依赖识别数据集构建与研究——以小红书旅游评论数据为例
人工智能·算法·yolo·目标检测·cnn·旅游
AI浩2 天前
DroneScan-YOLO:针对无人机图像中微小目标的冗余感知轻量级检测
yolo·无人机
YOLO数据集集合3 天前
天空反无人机检测数据集 | 无人机检测 多旋翼识别 固定翼识别 低空安防 目标检测9063期
人工智能·yolo·目标检测·计算机视觉·无人机·反无人机
深度学习lover3 天前
<数据集>蚜虫识别<目标检测>
人工智能·深度学习·yolo·目标检测·计算机视觉·蚜虫识别