
简介这套基于Python与深度学习全卷积网络实现的字体笔划分割解决方案源码面向计算机视觉、光学字符识别、字体还原及手写识别方向的开发者和研究者可用于理解并复现端到端的笔划分割模型与训练流程。压缩包共25个文件主体为约18个Python脚本覆盖数据读取、模型定义、训练预测、结果评估与图像绘制等模块同时附带环境配置文件、示例图片和简明说明文档整体约为200KB结构紧凑、依赖清晰。已有338人学习或下载适合需要从零搭建图像分割项目的读者快速参考。代码中除实现unet、segnet、fcn、mynet等典型分割网络外还提供miou、fwiou、mpa等评估脚本帮助读者系统掌握分割模型训练、验证、指标度量与可视化调优的完整闭环也可结合自备字体图片直接进行迁移训练与效果对比。1. 字体笔划分割为什么要用全卷积网络从一张带纹理的扫描图说起我真正被字体笔划分割逼到墙角是因为一张艺术字扫描稿。标题文字压在带纹理的底色上笔画内部有渐变色背景还透着下一页的墨迹传统阈值法和形态学轮番上阵怎么调参数都只能抠个轮廓笔画和背景的灰度分布几乎完全重叠。那周我意识到这类问题已经不适合用人工设计特征去硬扛了。换上全卷积网络之后输入和输出都是完整图像一次前向就能得到逐像素预测不需要为了分类某个像素而反复做滑窗速度和稳定性都提升了一个量级。这套源码正是围绕“字体笔划分割”组织的完整工程从 dataset.py 加载图片到 fcn、segnet、unet、mynet 四个模型组再到 train 系列训练入口最后用 miou、fwiou、mpa 做评估用 predict.py 出图。它不是那种跑个官方 demo 就结束的教学包而是可以直接替换成自己数据集、拿来当分割项目基线的实战源码。如果你正在做 OCR 前处理、书法汉字笔迹分析或者想把某一类文档元素从背景里干净地分离出来这个项目的代码骨架值得先铺一遍。2. 源码目录拆解17 个 Python 文件在分割流水线里怎么分工这套源码的目录初看有点杂根目录里既有 train-unet.py、train-fcn.py、train-segnet.py又有 train300.py、train.py还塞了一个 evaluate 相关脚本和 models 包。但真正把文件按数据流向理一遍后会发现它只是把一条完整分割流水线放到了四个区域数据读取、模型定义、训练入口、评估和预测。下面不按文件清单罗列而是按我复现这类工程时的阅读顺序来讲这样你打开压缩包后能直接对应到代码位置知道哪些文件是核心哪些只是辅助工具。2.1 数据入口dataset.py 和 countclass.py 决定模型能学到什么首先需要理解的是 dataset.py。几乎所有的分割工程里这个文件负责回答一个问题给模型的一对样本输入图像和标签掩膜是怎么对应起来的。字体笔划分割通常是二分类问题标签里 0 表示背景1 表示笔画区域。常见做法是图片放在 img 目录掩膜放在一个 mask 或 label 子目录文件名保持一致只是后缀不同。我按这套源码的常见目录结构补了一段关键逻辑如下所示# dataset.py 中常见的数据读取结构 import glob import cv2 import torch import numpy as np from torch.utils.data import Dataset class FontDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size(256, 256)): self.img_paths sorted(glob.glob(f{img_dir}/*.jpg)) self.mask_paths sorted(glob.glob(f{mask_dir}/*.png)) self.img_size img_size assert len(self.img_paths) len(self.mask_paths), 图片和掩膜数量不一致 def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, self.img_size).astype(np.float32) / 255.0 mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) mask (mask 127).astype(np.float32) mask cv2.resize(mask, self.img_size, interpolationcv2.INTER_NEAREST) img_tensor torch.from_numpy(img).permute(2, 0, 1) mask_tensor torch.from_numpy(mask).unsqueeze(0) return img_tensor, mask_tensor这段代码的核心点是掩膜必须用 IMREAD_GRAYSCALE 读成单通道并且 resize 时只能使用 INTER_NEAREST。这里 mask 一旦被转成 float 类型再配合大于阈值判断才能避免背景标签出现 0 到 255 之外的目标值。图像通道顺序也需要注意torchvision 类模型默认期望 RGB而 cv2 默认读出来是 BGR少一次 cvtColor 会让模型在训练集上表现很差换到其他数据时甚至完全失去鲁棒性。至于根目录下的 countclass.py它做的是统计每一张图里笔画和背景分别占了多少像素。这个脚本虽然在整条流水线里只占了一小步但避免后面踩“类别极不均衡”的坑时非常有用。我一般拿到数据的第一件事就是跑一下 countclass.py看看训练集里前景像素占比到底是不是已经低到 1% 以下如果连这步都不检查后面所有 mIoU 分数都可能只是虚假繁荣。2.2 模型组fcn.py、segnet.py、unet.py、mynet.py 怎么取舍models 目录下放的是四个网络结构分别是 fcn.py、segnet.py、unet.py 和 mynet.py。它们都是全卷积结构没有全连接层所以无论输入分辨率是多少都能输出对应尺寸的得分图。这也是“全卷积网络”在字体笔划分割里成立的根本原因图像进去分割图出来而不是像图像分类那样输出一个类别标号。模型文件核心结构在字体笔划分割中的特点fcn.pyFCN-8s主干做下采样后多次上采样恢复细节感受野大粗笔画分离好但细笔锋容易糊segnet.py编码器-解码器池化索引上采样边界锐利但对噪声更敏感unet.pyU 形结构跳跃连接拼接多尺度特征小目标保留最好最推荐作为首选 baselinemynet.py自定义轻量网络结构更精简参数少、训练快适合模型大了跑不动时做对比实际工程里我不会一上来就试四个模型而是先用 unet.py 跑通一个最基础的版本。原因很直接U-Net 的跳跃连接能把低层位置信息和高层语义信息拼在一起而字体笔划分割恰恰既需要理解“这属于哪个笔画”又需要知道“这个笔画精确落在哪个像素上”。对于笔画细、边缘毛刺多的艺术字U-Net 的边界保持能力通常明显好于普通 FCN。等 U-Net 效果稳定了再用 fcn.py、segnet.py 和 mynet.py 做横向对比看精度和参数量之间是否能取得更好的平衡。2.3 训练与评估train 系列文件、miou/fwiou/mpa 组成完整闭环训练入口分成 train.py、train-fcn.py、train-segnet.py、train-unet.py、train300.py 几个文件这在早期工程里很常见。作者没有把脚本抽象得太复杂而是给每个模型单独保留了一个入口这样想调某个网络的参数时不需要在一大坨 argparse 里翻找网络类型判断逻辑。train300.py 这个后缀里的 300通常指的是训练时把输入图统一缩放到 300 宽或 300×300。这类固定尺寸脚本写得很实惠测试时能让 batch 形状恒定不需要每次都在 collate 里做动态 pad。评估脚本放在根目录对应三个指标miou.py、fwiou.py、mpa.py。它们不是重复实现而是从三个不同侧面对分割结果打分mIoU 是语义分割最常用的指标把所有类别的 IoU 取平均前景类别占比越少它越容易被背景拖高。FWIoU 按类别像素占比加权在背景占绝对主导的字体场景里更能反映“大目标”分割得是否准确。MPA 只算像素分类准确率直观但完全会被大面积背景带偏只能作为辅助参考。把这几个文件和 predict.py、drawImg.py 串起来后流程就是train 系列脚本训练出模型权重predict.py 对测试图批量出分割结果然后 miou/fwiou/mpa 把预测结果和标签做比对最后 drawImg.py 把分割边界或者掩膜画回原图用肉眼检查细节。到这里这条源码流水线的闭环就基本形成了。3. 把训练跑起来环境、命令与 loss 不降时的排查逻辑很多人拿到这类源码第一反应是双击 train-unet.py结果第一步就挂在环境上。字体笔划分割用到的不只是 Python 基础语法还依赖 PyTorch、torchvision、OpenCV 这些深度学习基础库。版本对不上后面会冒出一堆莫名其妙的 AttributeError。所以我建议先花五分钟用仓库自带的 conda-env/env.yaml 把环境铺好再谈训练效果。3.1 用 env.yaml 把深度学习环境配置固定住conda-env 目录下的 env.yaml 是这份源码里最值得先看的配置文件之一。它把这个项目涉及的依赖版本固定好了能避免“我本地 Python 是 3.10装不上某个老版本 torchvision”这种问题。常见内容大致是下面这样name: font-seg channels: - pytorch - conda-forge dependencies: - python3.8 - pytorch1.12.1 - torchvision0.13.1 - numpy1.23 - opencv-python4.6.0 - pillow - tqdm - pip这里我把依赖拆开解释一下。Python 3.8 是兼容性最好的版本很多老代码在 3.9 以上会因为 numpy API 移除而报警告。PyTorch 1.12 和 torchvision 0.13 属于同一个周期版本不会出现 torchvision 读取不到模型层的兼容问题。OpenCV 在这里负责图片读取、resize 和可视化版本高一点没关系但尽量不要用 opencv-python-headless 替换因为在 drawImg.py 这种需要窗口交互的脚本里headless 版本会直接跳过显示。创建环境的命令很简单conda env create -f conda-env/env.yaml conda activate font-seg如果国内网络拉取慢可以先把 channels 里的地址替换成清华源或者阿里源但注意不要把 pytorch 所在的 channel 删掉否则 conda 会尝试从默认源下载 torch 相关包容易拿到 CPU 版本。创建完成后用python -c import torch; print(torch.cuda.is_available())确认 GPU 是否可用。输出 False 时不要急着买新卡先检查 conda 装的是不是 CPU 版 torch很多所谓的“训练特别慢”问题都出在这一步。3.2 训练入口train-unet.py 与 train300.py 的用法差异环境准备好之后我按惯例先看一遍 train-unet.py 的入口参数。这个脚本和 train300.py 在本质上没有区别差别主要在输入尺寸处理方式上。train300.py 会强制把图片缩放到 300 宽适合图片本身尺寸相差很大的场景train-unet.py 则可能保留原始尺寸或使用配置项里的 img_size。实际使用中我推荐直接用 train-unet.py把 img_size 改成 256 或 320这样显存压力可控对字体分割这种不需要超高分辨率的任务来说足够。如果脚本没有显式做成 argparse 形式也可以直接改文件末尾的配置参数。启动训练的命令大致如下python train-unet.py \ --data ./img \ --mask ./img/mask \ --epochs 150 \ --batch-size 8 \ --lr 1e-3 \ --outdir ./checkpoints我把这里每个参数展开讲一下。--data指向原图所在目录--mask指向掩膜目录目录名可能因源码实际结构略有不同跑之前先确认路径存在。--epochs设为 150 是一个相对保守的起点字体笔划分割数据量一般不大训练集从几百张到几千张都有可能150 轮足够网络收敛。--batch-size需要根据显存调整8 是一个保险值如果显卡只有 4GB改成 4 或者 2 更稳。--lr用 1e-3配合 Adam 优化器能规避掉很多手工设置学习率调度的麻烦。--outdir是模型保存位置训练过程会顺带把每一轮或者最优模型的权重写进去。训练过程中要关注的不是每一轮打印的 loss 数字本身而是 loss 的变化趋势。如果 10 轮内 loss 还在原始值附近波动说明要么是数据读取路径错了要么是学习率太大导致震荡先停下来检查 dataset.py 的输出张量里是否存在 NaN。另一个经验是前几轮 loss 快速下降然后进入缓慢下降段这是正常现象不要为了追求“曲线漂亮”而去疯狂调低学习率。3.3 评估闭环predict.py 跑批miou/fwiou/mpa 给观感打分训练结束后predict.py 负责把测试图片变成分割图。这个脚本通常需要一个模型权重路径和一个输入目录我一般这样调用python predict.py \ --checkpoint ./checkpoints/best_model.pth \ --input ./img \ --output ./result预测流程本质上是把图像 resize 成训练尺寸送入网络得到每个像素的得分图再用 0.5 作为概率阈值二值化。代码里常出现类似下面这样的核心片段# predict.py 中常见的预测与二值化逻辑 with torch.no_grad(): logits model(img_batch) prob torch.sigmoid(logits) mask (prob 0.5).float()这段代码有几个值得注意的细节。第一预测时一定要包在 torch.no_grad() 里否则显存会随着 batch 增大迅速被撑爆。第二sigmoid 把 logits 映射到 0 到 1 区间再取大于 0.5 作为阈值。如果你的模型输出层已经带了 softmax那么这里就不要再套 sigmoid否则结果会偏向某一类。第三阈值 0.5 并不是不可调的字体分割中如果漏检较多可以降到 0.3 或者 0.35多检较多就升到 0.6这个数值在后面的验证技巧里可以重点玩。结果目录生成之后再用 miou.py、fwiou.py、mpa.py 做数值评估。这三个脚本要放在同一个风格下跑否则它们内部对“前景类别序号”的定义不一样得出来的指标没有横向可比性。我通常的做法是先把预测结果和标注掩膜放在两个目录里文件名一一对应然后逐个执行python miou.py --pred ./result --gt ./img/mask python fwiou.py --pred ./result --gt ./img/mask python mpa.py --pred ./result --gt ./img/mask跑完以后把三个指标记到一张表里。如果 mIoU 高但 FWIoU 明显偏低说明模型对像素占比很小的前景笔画并不敏感如果 MPA 高得离谱而 mIoU 普通那大概率是背景类别占了九成以上需要用别的手段来处理类别不平衡问题。4. 实操避坑清单形状、插值、类别失衡和显存翻车修复分割项目在训练阶段出现的很多问题表面上看是“模型不行”实际上八成出在数据加载和参数设置上。我把这套源码里最容易踩的坑集中列出来每条按现象、原因、解决的顺序写方便你排查时直接对应。4.1 掩膜被读成三通道导致类别数错乱现象训练时 countclass.py 统计出的前景像素一直为 0或者模型预测结果把所有区域都当成背景loss 下降得特别慢。原因dataset.py 里读掩膜时没有加 cv2.IMREAD_GRAYSCALE。OpenCV 默认以三通道读取图片掩膜本来是单通道灰度图被读成三通道后每个像素变成 [0,0,0] 或 [255,255,255] 的向量。再经过 (mask 127) 判断时某些通道一致但整体不是纯黑或纯白的情况会导致标签混乱。更隐蔽的是如果代码里直接对三通道掩膜做 resize像素值可能被插值成中间值让标签出现第三种“灰色类别”。解决掩膜统一使用 cv2.IMREAD_GRAYSCALE 读取并在读完后立刻做二值化比如(mask 127).astype(np.float32)。在写 dataset.py 的时候我还会顺手加一句assert mask.max() 1.0防止标签值异常。4.2 mask resize 用了线性插值导致标签糊掉现象训练集 loss 正常下降但评估时轮廓明显偏粗笔画边缘像糊了一层雾甚至出现浅灰色边缘。原因resize 标签掩膜时用了默认的 cv2.INTER_LINEAR。线性插值会在 0 和 1 之间生成 0.4、0.7 这类中间值而分割标签应该是离散的。模型被迫去拟合这些纯属插值产生的“中间类别”分割边界自然不干净。解决所有标签图的 resize 一律用interpolationcv2.INTER_NEAREST。频率高的数据增强操作比如旋转、缩放也要找到对应的 nearest 选项。如果用了 PyTorch 的 transforms就避免使用 RandomResizedCrop 这类默认双线性插值的增强函数或者明确传参为 InterpolationMode.NEAREST。4.3 mIoU 很高但实际分割效果像没学透现象miou.py 跑出来 mIoU 超过 0.85但打开预测图却发现笔画残缺只是背景占最大面积拉高了指标。原因字体笔划分割中背景通常占 90% 以上如果模型全部预测为背景mIoU 也会很高这属于典型类别不均衡导致的指标失真。mIoU 是所有类别的均值一个小类别的 IoU 再低也会被大类别的高分稀释掉。解决不要只盯着 mIoU结合 FWIoU 和 MPA 一起看尤其关注前景这一类的 IoU。更积极的做法是在损失函数里给前景类加权。把原来的 BCE 换成 Dice Loss 或带权重系数的 CrossEntropyLoss权重系数可以先用 countclass.py 统计出的频率比值作为初始值然后手动微调。这样模型才不会因为“少预测点笔画”就获得大量虚假分数。4.4 FCN 上采样尺寸与输入尺寸对不齐现象运行 train-fcn.py 时中途报错像size mismatch或shape tensor不一致或者模型输出维度是输入的 1/2。原因全卷积网络经过几轮下采样后特征图尺寸已经缩小到输入的一半甚至 1/16最后必须用转置卷积或双线性插值恢复到原始尺寸。很多 FCN 实现里Upsample层写的是固定放大倍数比如 8 倍如果输入图宽高不是 16 的倍数8 倍上采样后会和原图尺寸差出几个像素拼接时直接报错。解决在代码里避免写死上采样目标尺寸改用动态获取。最简单的修复是把上采样层写成nn.Upsample(sizeinput_shape[2:], modebilinear, align_cornersFalse)。同时在 dataset.py 里把训练图片裁剪或缩放到 32 的倍数尺寸比如 288×288 或 320×320能省掉大量边界对齐问题。4.5 显存不足导致中途训练退出现象训练到第几个 batch 时弹出 CUDA out of memory再小的 batch 也只能勉强跑甚至 2 的 batch 也爆。原因分割网络对显存的需求比分类网络高得多。如果输入图很大又同时保存原始尺寸信息用于上采样中间特征图叠加起来很容易占满显存。另外训练阶段带反向传播的显存占用大约是纯推理的 3 到 4 倍所以用 predict.py 能跑的配置并不代表训练也能跑。解决优先把 batch-size 降到 2 或 1这是最直接的手段。如果还爆就把输入分辨率从 512 降到 320 或 256。注意同时要改 dataset.py 里的 img_size 和 上采样目标尺寸保持训练与预测一致。还有一个招数是把模型结构里的通道数整体除以 2比如 mynet.py 里如果第一层是 64 通道改成 32 通道。牺牲一点精度换取训练稳定性在前期验证阶段非常划算。5. 进阶验证给损失函数加上边界权重再用 drawImg.py 做肉眼复核数值指标跑通了不代表这个模型真的能用。我拿到这套字体笔划分割源码后最常用的一组进阶操作是在损失函数里加入笔画边界权重然后用 drawImg.py 把预测结果叠加到原图上做人工复核。这样能把指标上看不出来的局部问题直接暴露出来。首先是修改训练脚本里的损失函数。在纯 BCE Loss 之外可以加一个简单的 Dice Loss 分支# 以 train-unet.py 为基础改造的混合损失 import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): pred torch.sigmoid(pred) pred_flat pred.view(pred.size(0), -1) target_flat target.view(target.size(0), -1) intersection (pred_flat * target_flat).sum(dim1) loss 1 - (2.0 * intersection smooth) / (pred_flat.sum(dim1) target_flat.sum(dim1) smooth) return loss.mean() bce F.binary_cross_entropy_with_logits(logits, target) total_loss bce dice_loss(logits, target)这里 BCE 负责让预测概率尽量贴近真实分布Dice Loss 负责缓解背景占大的问题。smooth 参数设为 1.0 是为了避免某张图里前景像素为零时出现除零。修改后训练再跑 50 个 epoch通常前景类别的 IoU 会明显上升边缘也更完整。然后我用 predict.py 重新出图再用 drawImg.py 把掩膜叠加回原图python drawImg.py --img ./test/a.jpg --mask ./result/a.png --out ./overlay/a.jpgdrawImg.py 里常见做法是给掩膜区域加一层半透明颜色这样不会遮挡原始笔画细节得失一眼就能看出来。叠加图里如果白色掩膜明显比原笔画宽一大圈说明标签 resize 或插值有问题如果笔画中间出现空心说明模型对细长笔画欠拟合需要增加损失函数中的边界权重或者调低阈值。从那以后我每次换一批新字体数据都会强制走一遍这个流程先用 countclass 看类别占比再用 U-Net 跑首轮 baseline然后调整损失函数和阈值最后用 drawImg 出一批叠加图直接目检。数值和图像对得上模型才敢往下游用。希望这套源码里的思路在你那边也能少踩几个坑尽快把字体笔划分割跑出干净结果。本文还有配套的精品资源点击获取