☰
昏暗灯光下手写体也能存活?draw-your-font自适应阈值与墨水掩膜算法全解析
2026/10/26 10:12:19 网站建设 项目流程

【免费下载链接】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.

项目地址:https://gitcode.com/gh_mirrors/dr/draw-your-font
点击查看免费下载

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;
参数默认值含义
delta40像素必须比局部背景深多少才算墨水。低值更敏感(能捞住淡笔迹),高值更保守(抗阴影误判)
cap165墨水灰度绝对上限(归一化后)。比这亮的像素永远不算墨水

两个参数各司其职:

  • 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 让昏暗灯光下的手写体能"存活",靠的不是更激进的滤镜,而是两条朴素又扎实的原则:

  1. 相对判定:每个像素和它脚下的局部背景比,而不是和全局常数比——阴影从此只是背景的一部分;
  2. 双闸门 + 后处理: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.

项目地址:https://gitcode.com/gh_mirrors/dr/draw-your-font
点击查看免费下载

相关推荐

上一篇:Auto 模式翻车实录:LiteLLM 自适应路由在生产落地的 6 个坑
下一篇:让 AI 操控你的真实浏览器?动手之前,我劝你先想清楚这三件事

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询