
简介这份源码面向具备一定深度学习基础的开发者与计算机视觉方向的学习者提供一套基于Python与全卷积网络的字体笔划分割完整解决方案可用于字形结构分析、笔画提取、字体生成预处理等场景。资源包共25个文件以18个Python脚本为核心覆盖模型定义、训练与预测全流程并配有yaml环境配置、txt说明、png与jpg示例图及license等辅助文件压缩包约200KB结构紧凑便于快速上手。项目围绕FCN、U-Net、SegNet及自定义网络等多种分割架构展开同时集成mIoU、FWIoU、MPA等评价指标计算脚本与结果可视化模块方便对比不同模型的性能表现。目前已有338人学习下载适合希望深入理解全卷积网络在字体笔画分割任务中落地细节、并借助现成代码快速复现与二次开发的读者参考。1. 字体笔划分割为什么值得用全卷积网络重做一遍做字体设计或者字库生成的朋友大概率遇到过这种场景拿到一套手写扫描稿想把每个字的横竖撇捺拆成独立笔画再重新组装成矢量字形。传统做法靠 OpenCV 的骨架提取加角点检测遇到连笔、飞白、粗细变化大的字就集体翻车调参调到怀疑人生。这几年用 Python 做深度学习字体笔划分割逐渐成了主流方案核心思路是把笔划分割当成逐像素的语义分割任务用全卷积网络FCN端到端预测每个像素属于哪一笔。它解决的是「笔画边界模糊、粘连、断裂」这三类老大难问题适合有字库开发需求的设计团队、做 OCR 前处理预分割的工程师以及想拿一个完整分割项目练手深度学习的开发者。整套方案从数据标注、模型搭建、训练到推理导出全部可以用 Python 生态跑通源码结构清晰改起来不费劲。2. 全卷积网络做笔划分割的原理与数据准备2.1 为什么全卷积网络比逐块分类更适合笔画任务笔画分割的本质是像素级二分类或多分类每个像素要么属于某一笔要么属于背景。全卷积网络的最大特点是去掉了全连接层整个网络只由卷积、池化和上采样组成输入任意尺寸图像输出同尺寸的分割掩码。这和传统 CNN 逐块分类有本质区别——逐块分类要把图像切成小 patch每个 patch 单独预测中心像素类别推理时重叠区域重复计算速度慢且边界不一致。FCN 一次前向就出整图结果空间连续性天然更好。笔画还有几个特殊性质决定了 FCN 更合适。第一笔画是细长结构感受野需要足够大才能判断一个像素属于哪一笔FCN 的深层特征图正好提供大感受野。第二笔画宽度变化剧烈从 1 像素到几十像素都有需要多尺度特征融合FCN 的跳跃连接skip connection把浅层细节和深层语义拼在一起细笔画不会丢。第三字体笔画的拓扑结构复杂交叉点、转折点、端点都需要精确定位全卷积输出的概率图配合后处理能保留这些关键点。常见做法是选 U-Net 或 DeepLabV3 作为骨干。U-Net 的编码器-解码器对称结构加跳跃连接在小数据集上表现稳定适合字体这种标注成本高的场景。DeepLabV3 的空洞卷积和 ASPP 模块对大尺度上下文建模更强适合笔画风格差异大的多字体混合训练。我一般先用 U-Net 跑 baseline确认数据标注质量没问题再换 DeepLabV3 调优。2.2 笔画数据标注格式与目录组织数据是这套方案里最耗时的部分。每张字体图像需要一张对应的分割掩码掩码里每个像素值代表笔画类别。如果是单笔画分割只分笔画和背景掩码是二值图如果是多笔画分类横、竖、撇、捺、点各自一类掩码是灰度图像素值 0 到 N 对应不同笔画。标注工具推荐用 LabelMe 或 CVAT导出 JSON 后转成 PNG 掩码。目录结构建议这样组织dataset/ ├── images/ │ ├── train/ │ │ ├── 0001.png │ │ └── ... │ └── val/ │ └── ... ├── masks/ │ ├── train/ │ │ ├── 0001.png │ │ └── ... │ └── val/ │ └── ... └── classes.txtclasses.txt 每行一个类别名第一行是背景。图像和掩码文件名必须一一对应这是后面 DataLoader 能正确配对的前提。图像统一缩放到 512×512 或 768×768太小细笔画会糊太大显存吃不消。缩放时用最近邻插值处理掩码避免引入不存在的类别值。注意掩码像素值必须是整数类别索引不能是 0-255 的灰度渐变。如果标注工具导出的是彩色掩码需要写脚本按颜色映射表转成索引图否则训练时 loss 会直接爆炸。2.3 用 Python 写一个笔画数据增强与加载管道字体数据增强不能随便翻转旋转因为笔画方向有语义。水平翻转会把「撇」变成「捺」垂直翻转会把「横」的起笔收笔颠倒。安全的增强包括小角度旋转±10 度以内、弹性形变、随机粗细膨胀腐蚀、亮度对比度扰动。下面是一个基于 Albumentations 的加载管道import albumentations as A from albumentations.pytorch import ToTensorV2 import cv2 import numpy as np from torch.utils.data import Dataset class StrokeDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size512, augmentTrue): self.img_dir img_dir self.mask_dir mask_dir self.img_size img_size self.names sorted(os.listdir(img_dir)) if augment: self.transform A.Compose([ A.Resize(img_size, img_size), A.Rotate(limit10, border_modecv2.BORDER_CONSTANT, value0), A.ElasticTransform(alpha30, sigma5, p0.3), A.RandomBrightnessContrast(p0.3), ToTensorV2() ]) else: self.transform A.Compose([ A.Resize(img_size, img_size), ToTensorV2() ]) def __getitem__(self, idx): name self.names[idx] img cv2.imread(os.path.join(self.img_dir, name), cv2.IMREAD_GRAYSCALE) mask cv2.imread(os.path.join(self.mask_dir, name), cv2.IMREAD_GRAYSCALE) img cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) augmented self.transform(imageimg, maskmask) img_t augmented[image].float() / 255.0 mask_t augmented[mask].long() return img_t, mask_t def __len__(self): return len(self.names)这段代码的关键点Rotate的border_mode设为BORDER_CONSTANT且填充值为 0保证旋转后边缘不会出现虚假笔画。ElasticTransform的 alpha 和 sigma 控制形变强度alpha 越大扭曲越厉害字体任务建议不超过 40。掩码用long()转成 int64因为 PyTorch 的交叉熵损失要求目标为整数类别。图像归一化到 0-1 之间如果换成 ImageNet 预训练骨干需要改成对应的均值和标准差归一化。3. 从零搭建 FCN 笔画分割模型并跑通训练3.1 U-Net 骨干的编码器与解码器实现细节U-Net 的结构分编码器和解码器两半。编码器每层两个 3×3 卷积加 ReLU然后 2×2 最大池化下采样通道数从 64 翻倍到 512。解码器每层先上采样转置卷积或双线性插值和对应编码器层特征拼接再两个 3×3 卷积。最后一层 1×1 卷积输出类别数通道。import torch 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, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch3, num_classes2, features[64,128,256,512]): super().__init__() self.downs nn.ModuleList() self.ups nn.ModuleList() self.pool nn.MaxPool2d(2, 2) # 编码器 for f in features: self.downs.append(DoubleConv(in_ch, f)) in_ch f # 瓶颈层 self.bottleneck DoubleConv(features[-1], features[-1]*2) # 解码器 for f in reversed(features): self.ups.append(nn.ConvTranspose2d(f*2, f, 2, 2)) self.ups.append(DoubleConv(f*2, f)) self.final nn.Conv2d(features[0], num_classes, 1) def forward(self, x): skip [] for down in self.downs: x down(x) skip.append(x) x self.pool(x) x self.bottleneck(x) skip skip[::-1] for i in range(0, len(self.ups), 2): x self.ups[i](x) s skip[i//2] if x.shape ! s.shape: x nn.functional.interpolate(x, sizes.shape[2:]) x torch.cat([s, x], dim1) x self.ups[i1](x) return self.final(x)编码器通道数从 64 起步每下采样一次翻倍到瓶颈层 1024。解码器的转置卷积核大小和步长都是 2正好把特征图放大一倍。拼接前检查尺寸是否一致因为输入图像尺寸不是 2 的整数次幂时池化会取整导致尺寸对不上用interpolate兜底。最后一层 1×1 卷积把 64 通道压到类别数二分类就是 2。3.2 损失函数选型交叉熵、Dice 与 Focal 的取舍笔画分割有个天然难题背景像素远多于笔画像素正负样本极度不平衡。一张 512×512 的图笔画可能只占 5% 到 15% 的像素。纯交叉熵会让模型倾向于全预测背景准确率看着很高但 IoU 惨不忍睹。常见做法是交叉熵加 Dice 损失加权组合。Dice 系数直接优化预测和真值的重叠度对类别不平衡不敏感。Focal Loss 则通过降低易分类样本的权重让模型聚焦难分的边界像素。我的经验是二分类笔画任务用0.5 * CE 0.5 * Dice最稳多笔画分类任务用0.3 * CE 0.7 * Dice因为多类 Dice 能更好处理小类别笔画比如「点」。class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, logits, targets): probs torch.softmax(logits, dim1) targets_onehot nn.functional.one_hot(targets, num_classeslogits.shape[1]) targets_onehot targets_onehot.permute(0,3,1,2).float() intersection (probs * targets_onehot).sum(dim(0,2,3)) union probs.sum(dim(0,2,3)) targets_onehot.sum(dim(0,2,3)) dice (2. * intersection self.smooth) / (union self.smooth) return 1 - dice.mean() # 组合损失 ce nn.CrossEntropyLoss() dice DiceLoss() def criterion(logits, targets): return 0.5 * ce(logits, targets) 0.5 * dice(logits, targets)Dice Loss 里smooth防止分母为零。one_hot把整数掩码转成 one-hot再 permute 成 NCHW 格式和概率图对齐。注意 Dice 是对每个类别单独算再平均小类别不会被大类别淹没。如果发现边界还是糊可以把 CE 换成 Focal Lossgamma 设 2.0alpha 设 0.25。3.3 训练循环、学习率调度与显存优化参数训练循环本身不复杂关键是几个超参的设置。批量大小受显存限制512×512 输入下 U-Net 大概能跑 batch size 8 到 16取决于显卡显存。优化器用 AdamW初始学习率 1e-3权重衰减 1e-4。学习率调度用 CosineAnnealingWarmRestarts周期设 10 个 epoch避免后期震荡。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet(in_ch3, num_classes2).to(device) optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingWarmRestarts(optimizer, T_010, T_mult2) scaler torch.cuda.amp.GradScaler() # 混合精度 for epoch in range(100): model.train() for img, mask in train_loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): out model(img) loss criterion(out, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step() # 验证阶段算 IoU model.eval() with torch.no_grad(): for img, mask in val_loader: img, mask img.to(device), mask.to(device) pred model(img).argmax(dim1) # 累计混淆矩阵算 IoU混合精度训练autocastGradScaler能省 30% 到 40% 显存速度也快不少。T_mult2让每次重启的周期翻倍前期快速收敛后期精细调。验证阶段不要用criterion的 loss 值判断好坏直接算 IoU 或 Dice 系数这两个指标和最终分割质量直接挂钩。如果验证 IoU 连续 15 个 epoch 不涨就停。提示如果显存不够先把输入降到 384×384 跑通流程确认模型能过拟合一小批数据比如 20 张图再逐步放大。过拟合都做不到说明网络结构或损失函数有问题别急着加数据。4. 推理、后处理与笔画分割结果导出4.1 滑窗推理处理超大字体图像实际字体图像可能远大于 512×512比如整页手写扫描稿。直接缩放会丢细节正确做法是滑窗推理把大图切成有重叠的小块每块单独预测再把结果拼回去。重叠区域取平均或投票消除拼接缝。def sliding_window_inference(model, image, window512, stride256, num_classes2): model.eval() h, w image.shape[:2] prob_map np.zeros((num_classes, h, w), dtypenp.float32) count_map np.zeros((h, w), dtypenp.float32) for y in range(0, h, stride): for x in range(0, w, stride): y1, y2 min(y, h-window), min(ywindow, h) x1, x2 min(x, w-window), min(xwindow, w) patch image[y1:y2, x1:x2] tensor torch.from_numpy(patch).permute(2,0,1).float().unsqueeze(0) / 255.0 tensor tensor.to(next(model.parameters()).device) with torch.no_grad(): out torch.softmax(model(tensor), dim1)[0].cpu().numpy() prob_map[:, y1:y2, x1:x2] out count_map[y1:y2, x1:x2] 1 prob_map / np.maximum(count_map, 1) return prob_map.argmax(axis0)stride设为window的一半保证重叠区域足够大。min(y, h-window)处理边缘块防止越界。概率图累加后除以计数图取平均比直接覆盖更平滑。如果类别多可以改成投票机制但平均概率通常够用。4.2 形态学后处理去掉毛刺和孤立小区域网络输出难免有噪点笔画边缘的毛刺、背景里的小误检块。后处理用形态学操作清理。先开运算先腐蚀后膨胀去掉小噪点再闭运算先膨胀后腐蚀填平笔画内部小孔洞。最后用连通域分析去掉面积小于阈值的区域。import cv2 import numpy as np def postprocess(mask, min_area50, kernel_size3): kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 开运算去噪 mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 闭运算填洞 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 连通域过滤 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(mask, connectivity8) clean np.zeros_like(mask) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] min_area: clean[labels i] mask[labels i] return cleankernel_size一般设 3太大细笔画会被腐蚀断。min_area根据图像分辨率调512×512 下 50 像素比较合适大图按比例放大。连通域分析用 8 邻域因为笔画斜向连接多。如果发现笔画被误删先把min_area降到 20 试试。4.3 导出 SVG 矢量笔画与类别颜色映射分割掩码是栅格图字库开发需要矢量轮廓。用cv2.findContours提取每个笔画的轮廓再用svgwrite或直接拼 SVG path 导出。多笔画分类时每个类别给不同颜色方便检查。import svgwrite def mask_to_svg(mask, output_path, class_colorsNone): dwg svgwrite.Drawing(output_path, profiletiny) h, w mask.shape dwg.viewbox(0, 0, w, h) classes np.unique(mask) for cls in classes: if cls 0: continue binary (mask cls).astype(np.uint8) * 255 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) color class_colors.get(cls, black) if class_colors else black for cnt in contours: if cv2.contourArea(cnt) 10: continue points cnt.squeeze().tolist() if len(points) 3: continue dwg.add(dwg.polygon(points, fillcolor, strokenone)) dwg.save()RETR_EXTERNAL只取外轮廓笔画内部孔洞比如「口」字中间需要再用RETR_CCOMP取内轮廓做镂空。contourArea过滤掉太小的轮廓。导出的 SVG 可以直接拖进 Illustrator 或 FontForge 继续编辑。颜色映射表按类别索引给比如横用红色、竖用蓝色方便肉眼核对分割对不对。5. 笔画分割落地时最容易踩的五个坑5.1 掩码像素值不是从 0 开始的连续整数现象训练 loss 一直是 nan 或者大得离谱模型完全不收敛。原因标注工具导出的掩码像素值是 0、128、255 这种或者从 1 开始编号而CrossEntropyLoss要求类别索引在[0, num_classes-1]范围内。解决写一个检查脚本打印掩码所有唯一值确认是[0, 1, 2, ...]连续整数。如果不是用映射表重映射。5.2 图像和掩码文件名大小写不一致现象DataLoader 报FileNotFoundError但明明文件就在目录里。原因Linux 文件系统区分大小写0001.PNG和0001.png是两个文件。标注工具导出时可能改了扩展名大小写。解决统一用脚本批量重命名或者加载时用os.path.splitext匹配主文件名忽略扩展名差异。5.3 验证集 IoU 虚高但实际分割稀烂现象验证 IoU 0.95但推理出来的掩码全是背景。原因验证集里背景占比太高模型全预测背景也能拿高 IoU。解决算 IoU 时只对笔画类别算或者用mean IoU对所有类别等权平均。更直接的办法是每训几个 epoch 就存几张预测图肉眼检查。5.4 转置卷积导致棋盘格伪影现象分割掩码上出现规律性的网格状纹理笔画边缘不平滑。原因转置卷积的卷积核重叠不均匀步长和核大小不匹配时产生棋盘效应。解决把ConvTranspose2d换成nn.Upsample(modebilinear)加一个 3×3 卷积或者用nn.PixelShuffle。U-Net 里双线性上采样效果通常不比转置卷积差。5.5 推理时忘了切 eval 模式导致 BatchNorm 统计量漂移现象训练时指标正常推理时结果完全不对。原因模型还在train()模式BatchNorm 用当前 batch 的均值和方差单张推理时统计量偏差巨大。解决推理前必须model.eval()并用torch.no_grad()包住前向。如果部署到 ONNX 或 TensorRT导出时也要确认 BatchNorm 被正确折叠。6. 把笔画分割模型压到能实时跑的进阶技巧模型训好只是第一步真正落地时推理速度往往不达标。512×512 输入下 U-Net 在 CPU 上跑一张要 1 到 2 秒批量处理字库根本等不起。我一般从三个方向压模型剪枝、量化和知识蒸馏。剪枝最直接。PyTorch 自带torch.nn.utils.prune对卷积层做 L1 范数剪枝去掉 30% 到 50% 的通道再微调 10 个 epochIoU 通常只掉 1 到 2 个点。量化用torch.quantization.quantize_dynamic把权重转成 int8CPU 推理速度能翻倍精度损失很小。知识蒸馏则是用大模型DeepLabV3教小模型MobileNetV3 骨干的 U-Net小模型学大模型的软标签精度能逼近大模型但参数量只有十分之一。# 动态量化示例 import torch.quantization model.eval() quantized_model torch.quantization.quantize_dynamic( model, {nn.Conv2d, nn.Linear}, dtypetorch.qint8 ) torch.save(quantized_model.state_dict(), stroke_unet_quantized.pth)量化后的模型只能跑 CPU 推理GPU 上反而慢部署时要看目标硬件。如果目标平台是移动端直接导出 ONNX 再用 NCNN 或 MNN 转换速度比 PyTorch 原生快 3 到 5 倍。验证压缩后模型有没有掉点别只看整体 IoU。笔画分割的关键指标是边界 F1 分数Boundary F1它专门衡量预测边界和真值边界的重合度。整体 IoU 高但边界 F1 低说明笔画轮廓糊矢量化后线条会抖。我习惯在验证集上同时打印 IoU、Dice 和 Boundary F1三个都达标才算过。最后说个血泪教训别在训练集上调后处理参数。形态学的 kernel 大小、连通域面积阈值这些一定要在独立验证集上选。我有次在训练集上把 min_area 调到 30 效果完美换到测试集直接删掉了三个「点」笔画返工重标数据花了两天。现在我的习惯是训练前就切好 train/val/test 三份test 集在最终导出前绝不碰。希望帮到你。本文还有配套的精品资源点击获取