【免费下载链接】draw-your-font
Turn a photo of your handwriting into a real font (TTF/WOFF/WOFF2) - free, open source, no uploads. Node CLI + Claude Code skill.
draw-your-font 是一个免费开源的 Node 命令行工具,能把一张手写照片直接变成可安装的字体文件(TTF/WOFF/WOFF2),全程在本地运行、无需上传。它的核心难点在于:普通拍照条件——昏暗灯光、螺旋装订、页面阴影、灰色模板辅助线——都会让传统"一刀切"二值化失效。本文完整拆解 draw-your-font 如何解决这个问题:自适应阈值如何估计局部背景、墨水掩膜如何生成,以及后续如何把掩膜变成带阅读顺序的字符块。
上面就是官方展示的一次真实结果:昏暗灯光、螺旋装订、页面阴影,一张照片进去,可安装字体出来。
🌒 昏暗灯光为什么是"字体杀手"
手写转字体工具的第一道关卡是二值化:把照片里"算墨水的像素"和"算纸的像素"分开。传统方案用一个全局阈值——像素灰度低于 128 就算黑墨。
这在昏暗照片上会翻车:
- 光照不均:台灯下一侧亮、一侧暗,暗侧整片纸面都会被误判成墨水;
- 页面阴影:翻开的笔记本边缘有大片渐变阴影,阈值一放就会被吞进来;
- 模板辅助线:draw-your-font 的打印模板里有浅灰色格线,全局阈值无法区分"浅灰辅助线"和"浅色圆珠笔字迹";
- JPEG 噪点:压缩噪点会让笔画边缘掉出一堆细碎小点,变成几十个假轮廓。
draw-your-font 的答案是:不给每个像素一个固定标准,而是给每个像素一个"和它身边的纸比"的标准。这就是自适应阈值(adaptive threshold)。
📸 墨水掩膜是怎么算出来的
核心逻辑全部在 src/capture.js 的binarize()函数里,输入一张照片,输出一个 0/1 数组(ink,1 = 墨水)。整个流程分四步。
第 1 步:归一化 + 中值滤波打底
照片先经过 EXIF 方向纠正(手机竖拍不会被读歪)、转灰度、对比度归一化(normalise),然后做median(3)中值滤波。中值滤波是 JPEG 边噪的经典解药——capture.js 第 25 行 的注释写得直白:
stabilizes JPEG edge noise so stroke edges don't shed speck components (稳定 JPEG 边缘噪声,笔画边缘才不会抖出碎片组件)
这一步还顺带做了分辨率封顶:最长边超过 4200px 会被压到 4200,够精细又不至于白算。
第 2 步:估计"局部背景"——对抗阴影的关键
这是整个算法最巧的一步。代码把整张图缩小到 1/32 → 模糊 2px → 再放大回原尺寸,得到一张bg图(capture.js 第 30-36 行)。
这张图代表"如果这里没有字,纸面应该长什么样":
- 阴影是大范围、低频的灰度变化,缩小再放大后它被完整保留;
- 笔画是小范围、高频的细节,缩小过程中被抹平。
于是每个像素都有一个"局部纸面色值",阴影区域和明亮区域的判断标准各自不同——昏暗灯光下的阴影不再参与"墨水判定",而是被当成背景吸收掉了。
第 3 步:delta 与 cap 双闸门判定
有了背景,判定墨水需要同时满足两个条件(capture.js 第 38-41 行):
if (data[i] < cap && data[i] < bg[i] - delta) ink[i] = 1;| 参数 | 默认值 | 含义 |
|---|---|---|
delta | 40 | 像素必须比局部背景深多少才算墨水。低值更敏感(能捞住淡笔迹),高值更保守(抗阴影误判) |
cap | 165 | 墨水灰度绝对上限(归一化后)。比这亮的像素永远不算墨水 |
两个参数各司其职:
- delta 管"相对暗":解决光照不均,让暗区和亮区用各自的纸面色做基准;
- cap 管"绝对亮":模板上的浅灰辅助线虽然比局部背景略深,但灰度普遍高于 165,直接被天花板一刀切掉。这就是为什么 draw-your-font 敢让你把浅灰网格印在纸上——"网格是浅灰的,处理时自动消失,只有你的墨水能活下来"。
第 4 步:形态学闭运算,封住 1 像素裂缝
阈值判定后还有一个隐患:笔画边缘的阈值抖动会留下 1px 的裂缝和小孔,矢量化阶段会把它们认成几十个假轮廓。
解决办法是形态学闭运算:先膨胀(grow)再腐蚀(shrink),各做一次(capture.js 第 42-44 行)。膨胀把缝隙两侧的墨水"桥接"起来,腐蚀再把轮廓收回原大小——笔画形状不变,缺口没了。这个morph()函数和后面--weight粗细调整用的是同一套邻域扩散逻辑。
四步走完后,你得到的是一张干净的墨水掩膜:每个字符的笔画连通、完整,且没有阴影、没有辅助线。
✂️ 从掩膜到字符:Blob 检测与合并
掩膜本身只是"哪些像素是墨",还需要回答"哪里是一个字符"。这部分由 src/blob-core.js(纯几何计算,Node 和浏览器 demo 共用)和 src/segment.js(落盘与裁剪)完成。
连通域分析:先找到所有"墨团"
connectedComponents()用 8 邻域泛洪扫描整张掩膜,每团墨水得到一个包围盒。两道过滤器挡掉噪声:
- 最小面积:面积小于
max(30, 页面像素总数 × 3e-6)的碎片直接丢弃; - 最小尺寸:包围盒小于 4×4 像素的丢弃——segment.js 第 65 行 的注释很实在:"真实笔迹在手机照片分辨率下不可能只有 3px 细"。
多部件合并:i 的点、M 的断笔都要接回去
手写体里一个字母常常是多个不连通墨团:i/j上面的点、:和=的部件、甚至M/N/K这种笔画没接上的字母。mergeParts()用中位高度作为参照物,分三类合并:
| 场景 | 判定规则 |
|---|---|
| 上下堆叠(i 的点、冒号) | 一方小于半个中位字高,且水平重叠过半、垂直间隙小于 0.8 个中位字高 |
| 并排小标记(双引号) | 双方都小于半个中位字高,水平间隙小于 0.3 个中位字高 |
| 断笔字母(M、K) | 包围盒相交、垂直重叠 60% 以上,且合并后宽度不超过 1.6 个中位字高 |
有个容易忽略的细节:每轮合并都会重算中位高度,且只合并距离最近的一对。blob-core.js 第 61 行 注释解释了原因——阴影噪点堆会把中位数拉低,用旧的低中位数去合并,就会把上下两行的字母错误粘在一起。
阅读顺序:按"行聚类"排序
orderBlobs()按垂直重叠度把字符聚成行,再在行内从左到右排序,并给每个 blob 编号。它特意不用"中心点距离"聚类,因为g/y的下伸笔画(descender)会干扰判断。排序结果直接生成blobs.json,每个字符还会被裁成带白边的 PNG(crops/*.png),并输出一张画着红色编号框的联系表(contact-*.png)——标注时你只需要对这张图认字。
🔧 效果不对时:--delta 与 --cap 调参速查
这两个参数在segment和make命令上可调,官方维护了一张"症状 → 解法"对照表(skills/draw-your-font/references/troubleshooting.md),新手最实用的几行:
| 症状 | 调整 |
|---|---|
| 几百个碎小 blob(纸纹噪点) | --delta 55,或换更亮的照片 |
| 一大块 blob 横跨整页(阴影边缘) | 把照片裁到只剩纸面,或提高--delta |
| 字母整个消失(笔迹太淡) | --delta 25 --cap 190,实在不行换深色笔重写 |
| 笔画断成碎片(细圆珠笔) | --delta 30,再build --weight 1加粗 |
| 模板辅助线还残留 | --cap 140 |
| i/j 的点没被合并 | 正常会自动合并;没合并就把主框标字母、点标空 |
注意一个流程细节:重新跑segment会重写blobs.json并重新编号,之前写的labels.json要作废,建议直接换新工作目录。
🔗 后续链路:掩膜只是起点
墨水掩膜之后,draw-your-font 的完整流水线是这样的(来自 README.md):
photo ──► 自适应阈值 ──► blob 检测 ──► 标注 ──► potrace 矢量化 ──► 共享 em 方度量 ──► TTF/WOFF/WOFF2 + 预览- 矢量化:每个裁剪图交给 potrace(FontForge 和 Inkscape 同款引擎),src/trace.js 用
--smooth 0..2控制曲线圆润度; - 缠绕方向修正:potrace 默认偶数填充规则,而 TrueType 用非零缠绕规则,
o/b/g的字碗会整个填黑——src/winding.js 按嵌套深度重定向每条轮廓解决这个问题; - 共享度量:所有字符放进统一的 1000 单位 em 方,各自归位到 cap height / x-height / descender 对应的垂直带——这才是
g能垂到线以下、字体看起来像"字"而不是"拼贴画"的原因,实现在 src/metrics.js。
🚀 三条命令上手
# 自由格式照片,你自己知道书写顺序: npx draw-your-font make photo.jpg --chars "ABCabc" --name "My Hand" # → MyHand.ttf,双击安装,完事。 # 最高质量路线:打印模板 → 深笔填写 → 拍照: npx draw-your-font template -o template.pdf --charset minimal npx draw-your-font make page1.jpg page2.jpg --charset minimal --name "My Hand" # 阴影重的照片,加阈值参数: npx draw-your-font make photo.jpg --chars "ABC" --delta 55想装成 Claude Code 技能(AI 帮你认字标注)也可以:
npx skills add danilo-znamerovszkij/draw-your-font一切都在本地完成,你的手写照片不会离开你的电脑。完整命令参考见 src/cli.js,架构导览见 src/index.js。
总结
draw-your-font 让昏暗灯光下的手写体能"存活",靠的不是更激进的滤镜,而是两条朴素又扎实的原则:
- 相对判定:每个像素和它脚下的局部背景比,而不是和全局常数比——阴影从此只是背景的一部分;
- 双闸门 + 后处理:
delta管相对暗、cap管绝对亮,形态学闭运算兜底封缝,噪声层层被过滤。
再往后,连通域分析、基于中位高度的部件合并、按行聚类的阅读顺序,把一个 0/1 像素数组变成了一组有序、干净的字符裁剪图——矢量化和字体组装才真正有了干净原料。如果你想复现或改参数,从 src/capture.js 开始读,100 来行代码,注释全英文但非常诚实。
【免费下载链接】draw-your-font
Turn a photo of your handwriting into a real font (TTF/WOFF/WOFF2) - free, open source, no uploads. Node CLI + Claude Code skill.
相关推荐
3D Slicer BRAINSROIAuto 模块解析:基于 Otsu 阈值与形态学闭运算的自动前景 ROI 掩膜生成
3D Slicer BRAINSROIAuto 模块解析:基于 Otsu 阈值与形态学闭运算的自动前景 ROI 掩膜生成 本文围绕 3D Slicer 用户指南
医疗健康图像处理数据可视化GDAL `gdal raster sieve` 详解:用大小阈值合并清除栅格小面(4/8 连通、掩膜与流水线用法)
GDAL gdal raster sieve 详解:用大小阈值合并清除栅格小面(4/8 连通、掩膜与流水线用法) 本文是 GDAL 官方命令文档 gdal_ra
GIS数据处理FGO-py阈值处理:二值化与自适应阈值技术
FGO py阈值处理:二值化与自适应阈值技术 引言:游戏自动化中的图像处理挑战 在Fate/Grand Order(命运 冠位指定)这类手游自动化工具开发中,图
GUI 自动化计算机视觉桌面应用CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考