摘要 :本文完整记录一个工业视觉项目的全链路实现:用训练好的 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.copy2 比 copy 多保留修改时间等元数据。
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)
两个细节:
path用os.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 印章四分类业务逻辑
SealDetector 按 label(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 用绝对路径 |
七、总结
本文以古籍文档数字化为业务载体,完整走通了工业视觉项目的四大环节:
- 预标注:YOLOv8-seg ONNX 模型自动产出 LabelMe JSON,人工只需修正,标注效率提升数倍。核心难点是 letterbox 的正逆变换与掩膜系数合成。
- 格式转换 :
LabelMeToYOLOConverter以防御式编程处理中文路径、越界坐标、脏标签等真实世界的脏数据,自动完成数据集划分与 data.yaml 生成。 - 可视化质检:"垃圾进垃圾出",转换结果必须回画图像人工核对。
- 端到端部署:双模型级联(印章检测 → ROI 文字分割),配合上下半页分治、四分类版式处理、像素级通道染色,产出"黑白转彩色"的数字化成果。
贯穿始终的工程思想值得反复体会:模型即服务,一次封装多处复用 (WordSeg 同时服务预标注与生产环境);问题可见化 (越界告警、不支持的类型打印、转换统计);可复现性(随机种子、字母序类别、排序后的文件列表)。
参考资料:Ultralytics YOLOv8 官方文档、ONNX Runtime 文档、LabelMe 项目、PyMuPDF 文档。文中代码为课堂项目整理版,仅供学习交流。