简介:面向文字图像处理与语义分割研究者的字体笔划分割源码包,基于Python与深度学习框架实现,完整覆盖全卷积网络方案的训练、预测与评估链路。资源共25个文件,体量仅200KB,以18个Python脚本为核心,涵盖UNet、FCN、SegNet及自定义网络等多种模型实现,同时包含训练、预测、指标计算、数据集处理与结果绘制等模块,另附环境配置YAML、说明文档和示例图片,结构清晰便于直接上手。已有338人学习浏览,适合希望快速搭建笔画分割实验或深入理解全卷积网络原理的开发者。借助这份源码,读者可直观对比不同网络在字体笔划分割任务上的效果,学习miou、fwiou、mpa等评估指标的编写方式,并基于项目模块修改或扩展自己的分割模型;配合示例图片与可视化脚本,可快速验证算法效果并开展后续改进。
1. 全卷积网络做字体笔划分割:把每个像素分类成笔画
字体设计师想从一套黑体里拆出横、竖、撇、捺,OCR 团队想把复杂字形结构化,而你手里的字形图像只有几百张,传统形态学方法一遇到连笔和衬线就翻车。这个标题指向的,就是用 Python 写一个全卷积网络(FCN),把每个像素分类成「属于哪个笔画」或「笔画内部 / 背景」,从而拿到逐像素的笔划分割掩码。它解决的是从「能看到字」到「能拆开字」这一步,适合做字形学分析、笔画级数据集生成、书法风格迁移和字库生产的工程师。下面按我的落地顺序讲:为什么选 FCN、怎么搭最小方案、参数怎么调、以及源码跑起来后会踩哪些坑。
2. 为什么选全卷积网络:从滑窗分类到端到端分割的演进与选型
2.1 全卷积网络的核心:全连接换成卷积,让输出变成热力图
传统分类网络最后是几个全连接层,输出一个类别概率,但你要的是「每个像素属于哪一类」,这要求输出和输入都是二维图。全卷积网络的做法很简单:把最后的全连接层全部换成 1x1 卷积,并把前面池化层丢弃的空间信息通过上采样恢复回来。这样输入一张 256x256 的字形,输出就是 256x256x类别数 的逐像素得分图,最后接 softmax 得到每个像素的笔画标签。
我第一次做笔画分割时也犹豫过:为什么不用 Faster R-CNN 那样的目标检测?因为笔画不是一个个规则矩形框,撇和捺是细长曲线,检测框无法表达重叠和交叉。分割网络天然支持像素级分类,对细长结构友好得多。而这个标题里的「全卷积网络」强调的是端到端训练,不需要额外的候选区域或手工特征,源码实现时核心就两件事:下采样提取语义特征、上采样恢复分辨率。
2.2 U-Net 比原始 FCN 更适合小样本字形数据
原始 FCN 在 CVPR 上提出时用的是 VGG 做骨架,上采样部分直接反卷积,但下采样会丢掉精细边缘。U-Net 加入了跳跃连接:在每一层上采样的同时,把编码器同分辨率的特征图拼回来,相当于给解码器补了一份「高清底图」。字形笔画对边缘精度要求极高,撇捺的粗细变化只有几个像素,丢失边界信息会让分割结果像狗啃一样。
如果你的标注样本只有几百张,U-Net 的跳跃连接还有正面效果:浅层特征的梯度有了更短的通路,网络更容易收敛,不容易过拟合到深层语义。所以我的建议是,源码实现时以 U-Net 结构为主,只在网络命名上保留「全卷积」的说法。输入尺寸也不用追求大,256x256 或 512x512 就够,笔画特征在 32x32 的 feature map 上依然可辨。
2.3 数据集与标注:从字体文件到像素级掩码
做监督学习,第一步是拿到标注。字体文件(TTF/OTF)本身就是矢量轮廓,可以借此自动生成真值。常见做法是:用 Python 的fontTools库解析字形轮廓,按路径中的子路径拆分笔画,再光栅化为掩码。但矢量轮廓的笔画交叉点处理很麻烦,一个「横折钩」可能是一条路径,拆出来的掩码和人类认知的笔画并不一致。
我一般会用半自动流程:先用fontTools按轮廓拆出初始掩码,再人工修正交叉点,把相连但不属于同一笔画的区域手工断开。如果只有图片没有字体文件,那就只能用 LabelMe 或开源标注工具逐张画多边形,一张 256x256 的字形标注平均要花 5 到 10 分钟。这里强调一点:标注类别不要按「横竖撇捺」直接细分,除非你有大量数据。常见做法是先分三类——背景、笔画主体、笔画边界(或背景、主笔画、交叉区域),把交叉问题降级为后处理,训练难度小很多。
3. 用 PyTorch 实现笔划分割的最小可跑方案
3.1 环境准备与依赖安装
源码落地我建议直接用 PyTorch,生态成熟,torchvision里自带预训练模型,后续迁移学习非常方便。Python 版本建议 3.9 到 3.11,PyTorch 2.x 都支持。先建虚拟环境再装依赖,避免把系统 Python 弄乱。
python -m venv stroke-env source stroke-env/bin/activate # Windows 下用 stroke-env\Scripts\activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install fonttools numpy pillow matplotlib opencv-python tqdm这里第一行创建虚拟环境,第二行激活,第三行安装 GPU 版 PyTorch。cu118是 CUDA 11.8,如果你的显卡驱动更新,可以用cu121或直接去掉--index-url安装 CPU 版。最后一行是数据处理和可视化依赖,fonttools专门用来解析字体文件。装完以后跑一句python -c "import torch; print(torch.__version__, torch.cuda.is_available())",能看到True就说明 GPU 可用。
3.2 数据读取与预处理:把白底黑字变成 tensor
字形图像通常是白底黑字,但网络希望输入是[0,1]浮点数,且背景为 0,笔画为 1。我习惯把图像转成灰度,再反色,让笔画变白、背景变黑。同时标注掩码从 0 开始计数,背景固定为 0。
import cv2 import numpy as np import torch from torch.utils.data import Dataset class StrokeDataset(Dataset): def __init__(self, img_dir, mask_dir, size=256): self.img_paths = sorted(glob(img_dir + '/*.png')) self.mask_paths = sorted(glob(mask_dir + '/*.png')) self.size = size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx], cv2.IMREAD_GRAYSCALE) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (self.size, self.size), interpolation=cv2.INTER_AREA) mask = cv2.resize(mask, (self.size, self.size), interpolation=cv2.INTER_NEAREST) # 反色:白底黑字 -> 黑底白字,数值范围 [0,1] img = (255 - img) / 255.0 mask = (mask > 127).astype(np.int64) # 二值掩码,背景0,笔画1 img = torch.from_numpy(img).unsqueeze(0).float() # [1, H, W] mask = torch.from_numpy(mask).long() # [H, W] return img, mask逻辑说明:cv2.resize时图像用INTER_AREA做抗锯齿缩放,掩码必须用INTER_NEAREST,否则插值会产生不属于任何类别的中间值,导致标签错乱。反色那行把白底变成 0,黑字变成 1,正好对应背景和前景。最后转成 tensor,图像增加通道维度,掩码直接是长整型,因为交叉熵损失要求类别索引。
参数说明:size统一设置 256,对大多数笔画结构足够。如果字体包含极端复杂字形(比如繁体超过 50 画的字),建议改成 512,但显存占用会增加四倍。注意两个glob列表的顺序必须一致,我通常在生成文件名时用零填充编号保证排序。
3.3 FCN 模型定义:编码器、解码器、跳跃连接
这里给出一个精简的 U-Net 风格全卷积网络,省略 BatchNorm 和 Dropout 之外的细节,但保留了跳跃连接。重点是理解下采样和上采样的对称结构。
import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class FCNSegment(nn.Module): def __init__(self, in_channels=1, num_classes=2): super().__init__() # 编码器 self.enc1 = DoubleConv(in_channels, 64) self.enc2 = DoubleConv(64, 128) self.enc3 = DoubleConv(128, 256) self.pool = nn.MaxPool2d(2) self.bottleneck = DoubleConv(256, 512) # 解码器 self.up3 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2) self.dec3 = DoubleConv(512, 256) # 拼接后 512 通道 self.up2 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2) self.dec2 = DoubleConv(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2) self.dec1 = DoubleConv(128, 64) self.out_conv = nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) b = self.bottleneck(self.pool(e3)) d3 = self.up3(b) d3 = torch.cat([d3, e3], dim=1) d3 = self.dec3(d3) d2 = self.up2(d3) d2 = torch.cat([d2, e2], dim=1) d2 = self.dec2(d2) d1 = self.up1(d2) d1 = torch.cat([d1, e1], dim=1) d1 = self.dec1(d1) return self.out_conv(d1)逻辑说明:编码器逐级下采样,每层通道数翻倍,bottleneck是语义最抽象的部分。解码器的ConvTranspose2d做 2 倍上采样,然后把对应编码器特征图按通道维度cat起来,这会让通道数翻倍,所以DoubleConv的输入通道要写成拼接后的数量。最后用 1x1 卷积输出每个像素的类别得分,num_classes是 2(背景和笔画)或更多。
参数说明:初始通道数 64 是经验值,数据量大可以翻到 128,数据少建议降到 32,避免小数据集上过拟合。卷积核全部用 3x3,padding 1,保证特征图尺寸不因卷积改变。MaxPool2d是下采样的关键,它让感受野增大,但位置信息变粗,这块精度靠跳跃连接补回来。
3.4 训练脚本:损失函数、优化器、学习率调度
分割任务最常见的损失是交叉熵,但笔画图像背景占比往往超过 90%,交叉熵会被背景主导。我一般用 Dice Loss 和交叉熵的组合,直接让网络优化分割重叠度。
import torch import torch.nn.functional as F def dice_loss(pred, target, smooth=1.0): pred = torch.softmax(pred, dim=1)[:, 1] # 取笔画类概率 pred = pred.contiguous().view(pred.size(0), -1) target = target.contiguous().view(target.size(0), -1) intersection = (pred * target).sum(dim=1) return 1 - (2.0 * intersection + smooth) / (pred.sum(dim=1) + target.sum(dim=1) + smooth) def combined_loss(pred, target): ce = F.cross_entropy(pred, target) dice = dice_loss(pred, target) return ce + dice训练循环主体用 PyTorch 标准写法,优化器推荐 AdamW,初始学习率 1e-3,配合余弦退火。
from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model = FCNSegment(in_channels=1, num_classes=2).cuda() optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): model.train() for img, mask in dataloader: img, mask = img.cuda(), mask.cuda() pred = model(img) loss = combined_loss(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() print(f'epoch {epoch+1}: loss = {loss.item():.4f}')逻辑说明:combined_loss把交叉熵和 Dice 加在一起,交叉熵提供稳定的梯度,Dice 缓解类别不均衡。T_max=50表示 50 个 epoch 内学习率从 1e-3 余弦降到 0,epoch 数要和T_max一致,否则退火节奏错位。
参数说明:batch size在显存允许范围内尽量大,256x256 输入、2 类分割、64 初始通道,8 的 batch 大约占用 4-5 GB 显存。如果只有 6 GB,把 batch 降到 4,同时把编码器初始通道从 64 改成 32。weight_decay用 1e-4,过小的数据集上太大会让模型欠拟合,太小又起不到正则作用。
4. 训练结果看不准?评估指标与调参的 5 个关键点
4.1 IoU 和像素准确率的区别,为什么都要看
很多读者第一次跑分割,习惯看准确率,但背景占比 95% 时,准确率轻松 95%,模型只要全预测背景就赢麻了。正确做法是看 IoU(交并比),计算公式是预测和真值的交集 / 预测和真值的并集。笔画细小时,IoU 对偏移极其敏感——一个 2 像素宽的撇,偏移 1 个像素就损失一半交叠。
我训练时会打印每个类别的 IoU 和平均 IoU(mIoU)。具体到笔画分割,最关心的只有笔画类 IoU,背景 IoU 通常很高。如果笔画 IoU 低于 0.6,说明模型要么漏检细小笔画,要么边缘偏离。像素准确率只作参考,不用于判断训练是否成功。
4.2 学习率、Batch Size、输入尺寸的联动
学习率和 batch size 是绑定的。batch size 翻倍,梯度方差变小,可以尝试把学习率翻倍。但字形数据集通常很小,batch 从 8 加到 16,学习率从 1e-3 加到 2e-3 可能直接发散,因为样本间差异大(不同字的结构差异远大于自然图像)。我的做法是固定 batch 4 或 8,只调学习率,用 1e-3 起步,loss 不降就降到 3e-4。
输入尺寸也会影响最优学习率。512x512 的输入会让每个 batch 的像素数变为原来的 4 倍,有效样本数变大,学习率可以适当调高 50%。反之降到 128x128 时,要降低学习率,否则损失函数震荡。如果你用的是预训练编码器,内部 BatchNorm 对 batch size 很敏感,batch 不能小于 2,否则统计量不准。
4.3 类别不均衡:笔画细小,背景占大头,怎么处理
除了 Dice Loss,还有一个常见做法是给交叉熵加类别权重。背景权重设 0.1,笔画权重设 1.0,让模型更关注前景。但权重别设太极端,我试过背景 0.05、笔画 1.0,结果模型把大片背景误判成笔画,产生很多假阳性。Dice Loss 本质上已经处理了不均衡,如果加权重,建议背景 0.5、笔画 1.0 起步,观察预测掩码里噪声点数量再微调。
如果你的字体有粗体和细体之分,同一类笔画的像素数量差异也很大。黑体笔画粗,像素多,模型会偏向学粗笔画,细体字的撇捺容易丢。这种情况可以在数据加载时做在线增强:随机缩放、轻微旋转、腐蚀膨胀,把笔画粗细变化模拟出来。注意旋转角度控制在 5 度以内,字形旋转幅度太大不符合汉字结构。
4.4 验证集设计:按字形分组,别按像素随机分割
这是大部分源码里最容易踩的暗坑。如果你把所有字形的图像和掩码混合在一起,按 8:2 随机切分,同一个字的不同样本可能同时出现在训练集和验证集,验证集的 IoU 虚高。字形结构是高度相关的,某个笔画在训练集见过,验证集再考就没有意义。
正确做法是按字形分组,比如《现代汉语常用字表》的 2500 个字,按字分组随机切分。或者你只有一套字体的全部字形,那就按字在 Unicode 编码顺序隔 10 个取 1 个做验证。这样验证集能真实反映模型对未见过的字的泛化能力。我见过一个源码项目训练集 mIoU 0.95、验证集 0.98,换一批字直接掉到 0.4,就是验证集泄漏导致的。
5. 上千张字形跑崩的 4 个避坑记录
5.1 现象:loss 不降,模型全预测背景
训练开始后 loss 停在 0.7 左右不动,预测的掩码全黑,没有任何笔画区域。原因多是类别不均衡加 learning rate 太大,模型一上来就把所有像素推向背景,梯度被背景主导,笔画类的梯度被淹没。解决办法是先把学习率降到 3e-4,然后把损失函数换成 Dice Loss 主导,或者给背景类降权。我通常会在训练前先检查一下第一张输出的预测概率分布,如果笔画类概率普遍低于 0.1,就说明初始化有问题,而不是训练没跑起来。
5.2 现象:训练集 IoU 高,验证集暴跌
这是过拟合的典型表现。原因有几个:训练数据太少、模型太宽、数据增强太少。我遇到最隐蔽的原因是验证集和训练集有字形重叠,或者 Dataloader 的shuffle=True时用了同一个采样器导致数据泄漏。解决顺序是先检查数据切分布是否按字形分组,再把编码器初始通道从 64 降到 32,最后加随机平移和轻微模糊增强。如果仍然暴跌,检查验证集损失和训练集损失的 gap,gap 持续扩大就要考虑早停。
5.3 现象:同一字体训练出的模型换字体就失效
黑体训练完,换了宋体,笔画 edge 全碎,交叉点错乱。这不是模型 bug,而是字体风格差异太大。黑体笔画等宽、无衬线,宋体有粗细变化和衬脚,网络把「等宽」当成了隐含特征。解决方法是迁移学习:用黑体训练好的权重做初始化,再用少量宋体标注微调,学习率设 1e-4,训练 10 个 epoch。如果目标字体风格跨度更大(比如从印刷体到手写体),建议至少标注 50 张手写体字形做微调,不然白搭。
5.4 现象:显存不够,Batch Size 降到 2 还是崩
显存不足但 batch 降到 2 还崩,不一定是输入尺寸过大,而是解码器拼接特征图时通道数失控。342 的中间特征图在拼接后会达到 512 通道,一个 batch 就占掉不少显存。解决套路:改用混合精度训练torch.cuda.amp,把计算图从 fp32 降到 fp16,显存占用减少一半以上;同时把输入尺寸从 512 降到 384。另一个隐藏坑是 Dataloader 的num_workers开太多导致 CPU 内存不足而不是显存不足,报错信息里区分CUDA out of memory和DefaultCPUAllocator就很关键。
6. 从源码到工具:导出、批处理与迁移到新字体
6.1 导出为 TorchScript 或 ONNX,脱离训练环境推理
训练完成后,你不能指望生产环境也装 PyTorch 和显卡。常见做法是导出 TorchScript 或 ONNX,用 CPU 做推理。
model.eval() example = torch.randn(1, 1, 256, 256) traced = torch.jit.trace(model, example) traced.save('stroke_seg.pt')然后用torch.jit.load就能加载,不需要原来的网络定义。导出时把model.eval()放在前面,否则 BatchNorm 的统计量会跟着输入变化,导致推理结果漂移。ONNX 导出同理,注意输入输出名和动态维度要声明,否则换不同尺寸的字形会报错。
6.2 批量分割整个字体库:循环与后处理
拿到一张字形的像素级概率图后,还要转成可用的矢量结构。常见做法是argmax得到类别掩码,再用cv2.findContours提取笔画边界。批量处理几百个字形时,建议把每张图裁剪到包含笔画的 bounding box,再 resize 到 256x256,推理完再映射回原尺寸,这样小字形的笔画细节不容易被压缩掉。后处理时注意去噪:面积小于 20 像素的连通域直接删掉,它们通常是不稳定的零点,不是真实笔画。
6.3 迁移学习:用少量标注把模型搬到手写体
如果你已经有一套印刷体模型,要迁移到手写体,加载权重时冻结编码器的前三层,只训练解码器和后面两层。手写体变化大,但基础笔画结构类似,冻结浅层可以让模型保留对边缘和纹理的敏感度,只调整高层语义。
pretrained = torch.load('stroke_seg.pt') model.load_state_dict(pretrained.state_dict()) for name, param in model.named_parameters(): if name.startswith('enc1') or name.startswith('enc2'): param.requires_grad = False之后再微调 30 个 epoch,学习率 5e-5,mIoU 通常能从 0.2 拉回 0.7。这是我这几年做字形迁移项目时最常用的一条路径——先让模型在数据量大的黑体上打底,再用几个小时的标注成本把模型换到目标字体风格。跑通这一整套流程之后,我还要多说一句,笔划分割不是一次性活,每次换字体风格都要重新验证数据切分和损失权重,我吃过亏才明白:源码只是开始,真正有价值的是你手里那个参数调稳的模型。希望帮到你。
本文还有配套的精品资源,点击获取