把手机拍的手写签名照,变成透明底、能直接插进 Word / PDF 的 300dpi PNG。零参数:阈值自己定,不用你调。
需求听起来只有一句话:「把签名照的白纸去掉」。真正难的是下面这些 ——它们决定了这活是「五分钟画图软件魔术棒」还是一套要认真写的东西。
| 难点 | 现实是什么样 | 这里怎么解 |
|---|---|---|
| 光照不均 | 手机对着纸拍,同一张纸的纸底灰度从 90 到 230 都有(手挡出的阴影、桌面反光)。 一个固定阈值必然一头糊成块、另一头断成虚线,手调 level 每张都要试三轮。 | 先做背景归一化:用大核取局部最亮当作「纸」,把原图除掉它;再让 Otsu 在归一化后的「墨 / 纸」双峰上自己定阈值。默认零参数。 |
| 签名栏横线 | 签在有横线的签名栏上,笔画穿过横线 —— 两者本来就是同一个连通域, 整域的长宽比一点也不扁,「按细长判直线」那条路实测删除量为 0。 | 改用水平结构元开运算:只有连续水平长度 ≥ 35% 图宽的像素能活下来, 提出来的就是那条线本身。删完再纵向闭运算,把被切断的竖笔桥接回去。 |
| 纸纹与污点 | 拍照/扫描一定带一堆比笔画小得多的黑点,混进透明图印出来是脏的。 | 连通域面积过滤:小于主笔画面积 0.4% 的孤立域直接清零。剥线之后再扫一遍, 清掉被切断的横线残段。 |
| 插进 Word 尺寸失控 | 同样像素数的 PNG,插进 Word 可能落成 3cm 宽,也可能占满一页 —— 取决于文件里 有没有写 dpi,而浏览器导出的 PNG 默认不写。 | 导出时手工往 PNG 里插一个 pHYs 块写死 300dpi,并在卡片上直接告诉你
「按 300dpi 落下来有多宽(cm)」。 |
| 抠飞了看不出来 | 阈值判飞时,输出要么是一张几乎全空的图、要么整片黑,而程序退出码照样是 0。 这种错最贵:它会一路跟到盖章的文件上。 | fail-closed 闸:墨迹占比不在 0.3%–45% 之间就拒绝出图,并说明「这张可能不是 深墨浅纸的签名照」。宁可不给,不给一张假的。 |
| 签名是敏感件 | 一枚手写签名在流程上约等于一枚章。传上任何服务器都是白白多一处风险面。 | 纯前端:整条链路(读文件 → 计算 → 导出)都在浏览器里。没有上传接口, 也就没有「服务器上还留着一份」这个问题。 |
| 一套算法两个实现 | 批量走命令行(进文档流水线,一次几十张),交互走浏览器。两边各写一遍必然漂, 而漂了不会报错 —— 只是两边给出的图不一样。 | 同一批基准图跑数值对账:两边的 Otsu 阈值差 ≤ 3、墨迹占比差 ≤ 0.6% 才算过门,不过门不许发布。 |
| 环节 | 做法 |
|---|---|
| 背景归一化 | 核宽取图短边的 12%(大到能吞掉笔画宽度)做灰度膨胀,再均值滤波平滑,得到「这张纸本身 在每个位置有多亮」的估计;原图除以它 × 255。拍照的光照梯度、阴影、反光在这一步被压平。 |
| Otsu | 在归一化后的直方图上取类间方差最大的分割点。「墨 / 纸」是标准的双峰分布,Otsu 是这类问题的 教科书解 —— 关键是喂给它的必须是归一化之后的图,直接对原图做 Otsu 一样会被阴影带偏。 |
| 软 alpha | 阈值不做硬切:T×0.55 以下全不透明,T×1.06 以上全透明,中间线性过渡。
笔锋的抗锯齿因此被保留,放大 4 倍后边缘不会出现台阶。 |
| 形态学 | 膨胀 / 腐蚀 / 开 / 闭全部落在一个单调双端队列的一维滑动窗极值上,复杂度 O(n)
且与核宽无关,横竖各扫一遍即可分离实现。全程 Float32Array,没有第三方图像库。 |
| 剥横线 | 水平开运算提出直线 → 从 alpha 里减掉 → 在删除区做纵向闭运算桥接:只有该列在横线上下 都还有墨时竖笔才会被接回,纯横线区上下无墨不会复活。 并且变换生不生效必须拿前后差分作证 —— 没检出横线就明说「这次是空操作」, 不许静默通过(静默 no-op 是最难发现的假绿)。 |
| 300 dpi | 浏览器的 canvas.toBlob 不写 pHYs 块。导出时在 IHDR 之后手工插入一个
9 字节 pHYs(11811 像素/米 = 300dpi)并补上 CRC32。插进 Word 就按物理尺寸落地。 |
| 机器门 | 墨迹占比闸(0.3%–45%)、输出非空断言、批量时输入枚举为空即非零退出 —— 一律 fail-closed。 哑掉的守卫和它要防的 bug 是同一类东西。 |
| 两实现对账 | 命令行版(批量、进文档流水线)与浏览器版是同一套算法的两个实现,没有共享代码。 靠一批基准图的数值对账(Otsu ±3、墨占比 ±0.6%)+ 一条 headless 端到端 UI 用例 守住一致性,两门全绿才准发布。 |