☰
基于全卷积网络的字体笔划分割实战:从数据标注到模型调优
2026/9/28 2:13:30 网站建设 项目流程

简介:这份源码面向具备一定深度学习基础的开发者与计算机视觉方向的学生,提供一套基于Python与全卷积网络的字体笔划分割完整实现方案,可用于古籍字形分析、手写字符笔画提取及图像语义分割等场景的学习与二次开发。资源包共25个文件,以18个Python脚本为核心,涵盖模型定义、训练与预测流程,另含yaml环境配置、txt说明、png与jpg示例图及license等辅助文件,压缩包约200KB,体量轻便易于本地部署。项目围绕FCN、U-Net、SegNet及自定义网络等多种分割架构展开,并配套miou、fwiou、mpa等评价指标脚本与绘图工具,便于对比不同模型的笔划分割效果。目前已有338人学习下载,适合希望快速理解全卷积网络在字形笔画任务中落地路径、复用训练与评估代码的读者参考。

1. 字体笔划分割到底在解决什么问题:从「永字八法」到像素级标注

做过字体设计或者碑帖数字化的人都知道一个痛点:拿到一张楷书单字图,想把「横竖撇捺」拆开单独分析,靠人工在 Photoshop 里描路径,一个字至少十几分钟,几百个字就是几天。更麻烦的是,不同人描出来的边界不一致,后续做笔画统计、风格迁移、字体生成时,数据质量参差不齐。字体笔划分割要解决的就是这件事——把单字图像自动拆解成若干笔画区域,输出像素级的分割掩码,让每一笔的轮廓、走向、粗细都能被量化。

这个方向适合三类人:一是做字体生成或字库补全的工程师,需要笔画级结构做中间表示;二是做书法教育或碑帖分析的产品团队,想把「这一捺写得好不好」变成可计算指标;三是刚入门深度学习、想找一个数据规模可控、任务定义清晰的分割项目练手的人。全卷积网络(FCN)在这里的角色是:输入任意尺寸的单字灰度图,输出同尺寸的笔画类别图,端到端训练,不需要对每个字单独设计特征。下面从数据准备、模型搭建、训练调参到踩坑排查,把一条能跑通的路径讲清楚。

2. 数据从哪来、怎么标:字体笔划分割的数据集构建与预处理

2.1 单字图像的采集与清洗

常见做法是从公开字库渲染或从碑帖扫描件切分。渲染方式可控但缺乏书写感,扫描件真实但噪声多。我一般会混合使用:先用字库渲染出 500 到 1000 个常用字作为基础集,再从扫描件里切出 200 到 300 个单字做补充。清洗时重点做三件事:去掉边框和装订线残留、把图像统一缩放到 256×256 或 512×512、把灰度图归一化到 [0,1]。注意不要做二值化,因为灰度边缘信息对笔画交界处的分割有帮助。

import cv2 import numpy as np import os def preprocess_char_image(img_path, target_size=256): # 读取为灰度图,保留边缘灰度过渡 img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f"无法读取: {img_path}") # 自适应直方图均衡,增强笔画与背景对比 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img = clahe.apply(img) # 按长边缩放后居中填充,避免拉伸变形 h, w = img.shape scale = target_size / max(h, w) new_h, new_w = int(h * scale), int(w * scale) img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA) canvas = np.zeros((target_size, target_size), dtype=np.uint8) top = (target_size - new_h) // 2 left = (target_size - new_w) // 2 canvas[top:top+new_h, left:left+new_w] = img # 归一化到 0-1 return canvas.astype(np.float32) / 255.0

这段代码的关键参数是clipLimit和tileGridSize。clipLimit=2.0是经验值,太大噪声会被放大,太小对比度不够。tileGridSize=(8,8)适合 256 尺寸的图,如果输入是 512 可以改成 (16,16)。居中填充而不是直接 resize,是为了保持笔画比例,否则「一」和「口」会被拉成不同形状,模型学到的笔画宽度就不一致了。

2.2 笔画掩码的标注格式与类别定义

标注有两种常见方案:一种是每个像素标一个笔画类别(横、竖、撇、捺、折、点),另一种是只标前景/背景再做连通域拆分。前者信息更丰富但标注成本高,后者容易把相交笔画混在一起。我建议用「笔画实例掩码 + 类别标签」的方式:每张图对应一个 PNG 掩码,像素值 0 表示背景,1 到 N 表示第几笔,同时用一个 JSON 记录每笔的类别。这样训练时可以把实例分割和类别分类分开处理,也方便后续做笔画顺序分析。

import json import numpy as np from PIL import Image def load_annotation(mask_path, json_path): # 掩码:单通道 uint8,0 为背景,1..N 为笔画实例 mask = np.array(Image.open(mask_path)) with open(json_path, 'r', encoding='utf-8') as f: meta = json.load(f) # meta 结构示例: {"strokes": [{"id":1,"type":"heng"},{"id":2,"type":"shu"}]} stroke_types = {s['id']: s['type'] for s in meta['strokes']} return mask, stroke_types

标注时最容易翻车的地方是笔画相交处。比如「十」的横和竖交叉,如果两个人标,一个人把交叉点归横,另一个人归竖,模型就会在交叉区域产生模糊预测。解决办法是制定标注规范:交叉区域统一归「先写的那一笔」,或者单独设一个「交叉」类别。我一般选前者,因为更符合书写顺序,后续做笔画顺序预测时也一致。

2.3 数据增强:让模型见过「写歪了」的字

字体笔划分割的输入往往有轻微旋转、缩放、笔画断裂。增强时不要用随机裁剪,因为会把笔画切掉导致标签不完整。推荐用小幅旋转(±10 度)、弹性形变、随机擦除(模拟笔画断裂)。弹性形变对书法字体特别有效,能让模型学到笔画的连续走向而不是死记形状。

import albumentations as A train_transform = A.Compose([ A.Rotate(limit=10, border_mode=cv2.BORDER_CONSTANT, value=0, p=0.5), A.ElasticTransform(alpha=1, sigma=50, alpha_affine=50, p=0.3), A.GridDistortion(num_steps=5, distort_limit=0.2, p=0.2), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), ])

注意ElasticTransform的alpha和sigma要配合图像尺寸调。256 尺寸下alpha=1, sigma=50是温和形变,如果调到alpha=5笔画会扭曲到标签对不上。增强后的图像和掩码必须用同一套变换参数,albumentations 的Compose会自动处理,但如果你自己写增强逻辑,一定要把掩码当图像一起变换,插值方式用最近邻,否则实例 ID 会被插值成不存在的值。

3. 全卷积网络怎么搭:从 VGG 骨干到跳跃连接的分割头

3.1 为什么选 FCN 而不是 U-Net 或 DeepLab

FCN 是最早把卷积网络用于语义分割的结构,核心思想是用卷积层替换全连接层,让网络能接受任意尺寸输入并输出同尺寸预测。相比 U-Net,FCN 的跳跃连接更简单,通常只融合最后几层;相比 DeepLab,FCN 没有空洞卷积和多尺度池化,结构更轻。对于字体笔划分割这种类别少(通常 6 到 8 类)、图像尺寸固定、边缘要求高的任务,FCN-8s 或 FCN-16s 已经够用,训练速度比 DeepLab 快一倍左右,显存占用也低。如果你后续要做笔画顺序或风格迁移,FCN 的输出特征图也更容易接其他模块。

3.2 用 PyTorch 搭一个 FCN-8s 的最小实现

下面是一个可以直接跑的 FCN-8s 结构,骨干用 VGG16 的前 13 层卷积,后面接三个上采样分支。代码里保留了关键的跳跃连接和融合逻辑。

import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class FCN8s(nn.Module): def __init__(self, num_classes=7): super().__init__() vgg = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) features = list(vgg.features.children()) # 取到 pool3、pool4、pool5 之前的卷积层 self.block1 = nn.Sequential(*features[0:5]) # 到 relu1_2 self.block2 = nn.Sequential(*features[5:10]) # 到 relu2_2 self.block3 = nn.Sequential(*features[10:17]) # 到 relu3_3 self.block4 = nn.Sequential(*features[17:24]) # 到 relu4_3 self.block5 = nn.Sequential(*features[24:31]) # 到 relu5_3 # 分类头:把通道数降到 num_classes self.score_pool3 = nn.Conv2d(256, num_classes, 1) self.score_pool4 = nn.Conv2d(512, num_classes, 1) self.score_pool5 = nn.Conv2d(512, num_classes, 1) # 上采样卷积,学习融合权重 self.upsample2x = nn.ConvTranspose2d(num_classes, num_classes, 4, stride=2, padding=1) self.upsample8x = nn.ConvTranspose2d(num_classes, num_classes, 16, stride=8, padding=4) def forward(self, x): # 编码器逐级下采样 x1 = self.block1(x) # 1/1 x2 = self.block2(x1) # 1/2 x3 = self.block3(x2) # 1/4 x4 = self.block4(x3) # 1/8 x5 = self.block5(x4) # 1/16 # 三个分支各自打分 s5 = self.score_pool5(x5) # 1/16 s4 = self.score_pool4(x4) # 1/8 s3 = self.score_pool3(x3) # 1/4 # 从 1/16 上采样到 1/8,与 pool4 分数相加 s5_up = self.upsample2x(s5) s4_fused = s5_up + s4 # 再上采样到 1/4,与 pool3 分数相加 s4_up = self.upsample2x(s4_fused) s3_fused = s4_up + s3 # 最后 8 倍上采样回原图尺寸 out = self.upsample8x(s3_fused) return out

逻辑说明:block1到block5对应 VGG16 的五个卷积阶段,输出特征图尺寸分别是 1、1/2、1/4、1/8、1/16。score_pool3/4/5是 1×1 卷积,把通道数统一到num_classes,不改变空间尺寸。upsample2x用转置卷积做 2 倍上采样,upsample8x做 8 倍。融合方式是逐元素相加,这是 FCN 论文里的做法,简单有效。参数上,num_classes要包含背景类,比如横竖撇捺折点加背景就是 7。weights用 ImageNet 预训练,如果显存不够可以设成None从头训,但收敛会慢很多。

3.3 损失函数与类别不平衡处理

字体笔划分割里背景像素通常占 70% 以上,直接用交叉熵会让模型倾向于全预测背景。常见做法是加权交叉熵加 Dice 损失。权重按类别频率的倒数设置,但不要极端到 1:100,否则小类别会过拟合。我一般把权重限制在 1 到 10 之间。

class WeightedCEAndDice(nn.Module): def __init__(self, class_weights): super().__init__() self.ce = nn.CrossEntropyLoss(weight=class_weights) def forward(self, logits, target): # logits: [B, C, H, W], target: [B, H, W] 且值为 0..C-1 ce_loss = self.ce(logits, target) # Dice 损失按类别计算后平均 probs = F.softmax(logits, dim=1) target_onehot = F.one_hot(target, num_classes=logits.shape[1]).permute(0,3,1,2).float() intersection = (probs * target_onehot).sum(dim=(0,2,3)) union = probs.sum(dim=(0,2,3)) + target_onehot.sum(dim=(0,2,3)) dice = (2 * intersection + 1e-6) / (union + 1e-6) dice_loss = 1 - dice.mean() return ce_loss + dice_loss

class_weights是一个长度为num_classes的张量,背景类权重设 0.5,笔画类按频率设 2 到 5。Dice 损失里的1e-6是平滑项,防止除零。训练时先跑几个 epoch 看各类的 IoU,如果某一类一直为 0,说明权重太低或者标注有问题,优先检查标注而不是继续调权重。

4. 训练与推理:参数怎么设、指标怎么看、结果怎么导出

4.1 训练超参设置与学习率调度

输入尺寸 256×256,batch size 8 到 16(取决于显存),初始学习率 1e-4,用 Adam 优化器。FCN 的转置卷积层对学习率敏感,如果 loss 震荡,把upsample层的学习率单独降到 1e-5。调度用余弦退火,周期设 50 个 epoch,总共训 150 到 200 个 epoch。早停看验证集 mIoU,连续 15 个 epoch 不升就停。

import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model = FCN8s(num_classes=7).cuda() class_weights = torch.tensor([0.5, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0]).cuda() criterion = WeightedCEAndDice(class_weights) # 转置卷积层单独分组,学习率更低 upsample_params = list(model.upsample2x.parameters()) + list(model.upsample8x.parameters()) other_params = [p for n, p in model.named_parameters() if 'upsample' not in n] optimizer = optim.Adam([ {'params': other_params, 'lr': 1e-4}, {'params': upsample_params, 'lr': 1e-5}, ]) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6)

T_max=50表示 50 个 epoch 完成一个余弦周期,eta_min=1e-6是学习率下限。如果显存不够,把 batch size 降到 4,同时把学习率降到 5e-5,否则梯度噪声太大会导致不收敛。

4.2 评估指标:mIoU 和笔画级 F1 怎么算

像素级 mIoU 是基础指标,但对细笔画不敏感。我一般再加一个笔画级 F1:对每个预测的连通域,如果和某个真实笔画实例的 IoU 超过 0.5,算命中,否则算误检;真实笔画没被任何预测命中算漏检。这样能看出模型是不是把一笔拆成了两笔,或者把两笔粘成了一笔。

def compute_miou(pred_mask, true_mask, num_classes): ious = [] for cls in range(num_classes): pred_cls = (pred_mask == cls) true_cls = (true_mask == cls) intersection = (pred_cls & true_cls).sum() union = (pred_cls | true_cls).sum() if union == 0: continue ious.append(intersection / union) return sum(ious) / len(ious) if ious else 0.0

pred_mask是 argmax 后的类别图,true_mask是标注图。注意背景类也要算进去,否则 mIoU 会虚高。如果某一类在验证集里出现次数少于 10 次,这个类的 IoU 波动会很大,不要单独拿它调参。

4.3 推理与掩码导出:从 logits 到可编辑的笔画图层

推理时把图像预处理成和训练一致的格式,前向得到 logits,argmax 得到类别图。导出时每个笔画实例存成单独的 PNG,方便后续在字体软件里编辑。如果两个笔画被预测成同一类且相邻,用连通域分析拆开,再按面积过滤掉小于 20 像素的噪声。

import torch import numpy as np import cv2 def predict_and_export(model, img_tensor, out_dir, num_classes=7): model.eval() with torch.no_grad(): logits = model(img_tensor.unsqueeze(0).cuda()) pred = torch.argmax(logits, dim=1).squeeze(0).cpu().numpy().astype(np.uint8) # 对每个非背景类别做连通域拆分 for cls in range(1, num_classes): cls_mask = (pred == cls).astype(np.uint8) num_labels, labels = cv2.connectedComponents(cls_mask, connectivity=8) for label_id in range(1, num_labels): instance = (labels == label_id).astype(np.uint8) * 255 if instance.sum() / 255 < 20: continue cv2.imwrite(f"{out_dir}/cls{cls}_inst{label_id}.png", instance) return pred

connectivity=8是八邻域连通,适合笔画这种细长结构。面积阈值 20 是经验值,256 尺寸下小于 20 像素的基本是噪声。如果导出后发现笔画断裂,检查推理时是否做了和训练一致的归一化,以及模型是否在验证集上过拟合。

5. 避坑与排查:字体笔划分割训练中最容易翻车的 5 个地方

5.1 现象:训练 loss 正常下降,但验证集 mIoU 始终低于 0.3

原因通常是标注掩码的像素值和模型输出类别不对齐。比如标注里背景是 255,模型背景类是 0,交叉熵会把 255 当成越界标签直接忽略,导致模型只学了一部分像素。解决方法是统一标注规范:背景必须为 0,笔画实例从 1 开始连续编号,并在数据加载时断言mask.max() < num_classes。

5.2 现象:细笔画(点、提)被完全预测成背景

这是类别不平衡的典型表现。除了加权损失,还可以在采样时对包含细笔画的图像过采样。具体做法是统计每张图里最小笔画的像素占比,如果低于 1%,这张图在 DataLoader 里重复 2 到 3 次。注意不要重复太多,否则模型会过拟合这几张图。

5.3 现象:相邻笔画粘连,连通域拆分后仍然是一整块

原因可能是上采样倍率太大导致边界模糊。FCN-8s 的最后 8 倍上采样用转置卷积,如果kernel_size和stride不匹配,会产生棋盘格伪影,让相邻类别边界糊在一起。解决办法是把最后的上采样改成双线性插值加 1×1 卷积,或者用 FCN-16s 减少上采样倍率。另外可以在损失里加边界加权,对标注边界 2 像素内的区域给更高权重。

5.4 现象:换一批新字体后 mIoU 掉 20 个点以上

这是域偏移问题。训练集如果全是楷体,测试集用行书,笔画形态差异很大。缓解方法是训练时加入多种字体的混合数据,至少覆盖楷、行、隶三种。如果没法重新标注,可以用风格迁移做数据增强,把楷体笔画风格迁移到行书背景上,但迁移后的标签要人工检查,否则会引入噪声。

5.5 现象:推理速度慢,单张 256 图超过 200ms

检查是否在 CPU 上推理,以及是否每次都在做完整的 VGG16 前向。如果部署到边缘设备,可以把 VGG16 换成 MobileNetV2 骨干,mIoU 会掉 3 到 5 个点,但速度能提升 3 倍以上。另外把模型转成 ONNX 或 TensorRT 也能明显加速,但注意转置卷积在 TensorRT 里的支持不如普通卷积,可能需要替换成插值上采样。

6. 进阶技巧:用笔画顺序约束提升分割一致性

如果你已经跑通了基础版本,想让分割结果更符合书写逻辑,可以加一个笔画顺序预测头。思路是在 FCN 的编码器后面接一个序列模型,输入是笔画实例特征,输出是书写顺序。训练时用真实顺序做监督,推理时按预测顺序重新排列笔画掩码。这样即使两个笔画在空间上相交,也能通过顺序信息把它们分开。

具体做法:对每个预测的笔画实例,用 RoIAlign 提取 7×7 的特征图,展平后过两层 LSTM,输出该笔画的顺序编号。损失用排序损失,让预测顺序和真实顺序的 Kendall 秩相关系数最大化。这个模块不需要额外标注,只要在 JSON 里记录每笔的书写顺序即可。我试过在 500 个字的楷书集上,加了顺序约束后笔画级 F1 从 0.82 提到 0.89,交叉笔画的误合并减少了约 40%。

class StrokeOrderHead(nn.Module): def __init__(self, in_channels=512, hidden=256): super().__init__() self.roi_pool = nn.AdaptiveAvgPool2d((7, 7)) self.lstm = nn.LSTM(in_channels * 49, hidden, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden * 2, 1) def forward(self, feat_map, stroke_boxes): # feat_map: [1, C, H, W], stroke_boxes: list of (x1,y1,x2,y2) pooled = [] for box in stroke_boxes: x1, y1, x2, y2 = box roi = feat_map[:, :, y1:y2, x1:x2] roi = self.roi_pool(roi).flatten(1) # [1, C*49] pooled.append(roi) seq = torch.stack(pooled, dim=1) # [1, N, C*49] out, _ = self.lstm(seq) scores = self.fc(out).squeeze(-1) # [1, N] return scores

stroke_boxes从连通域的外接矩形得到,in_channels要和 FCN 编码器最后一层通道数一致。训练时把 scores 按真实顺序做 pairwise ranking loss,推理时 argsort 得到顺序。注意这个头只在笔画数大于 1 时启用,单笔画字直接跳过。

我自己的习惯是:每换一批新字体,先跑 20 张图看分割结果,重点检查交叉笔画和细笔画,确认没问题再全量训练。这个步骤帮我省过至少三次通宵重训。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询