前阵子整理归档材料,翻出一堆手机拍的会议记录和合同扫描件。图片校正漂白工具这种需求,平时觉得可有可无,真到了要打印、要转文字、要发给别人的时候才发现,歪斜、发黄、阴影真是三座大山。我一怒之下花几个晚上写了个本地小工具,名字就叫纸净Free。核心配置就两条:一是校正透视变形,二是漂白纸张背景。它永久免费,不注册、不联网、无次数限制,操作窗口里校正和漂白的效果实时预览。这篇不是广告,是把我实现它的思路、踩过的坑和参数心得完整摊开。
如果你也经常拍文档、翻拍旧书、处理发票和课件截图,或者单纯受不了一堆灰蒙蒙的扫描件,这篇可以当一份实现笔记看;如果只是想找免费工具,那我建议你照着里面的算法自己搭一条最小链路,会比四处找软件更快。
1. 被“歪、黄、花”的扫描件逼出来的免费小工具
1.1 手机拍文档的三宗罪
先说清楚问题出在哪。手机拍A4纸,几乎没有一张能保证镜头平面和纸面平行,于是纸的矩形边缘变成了任意四边形:远端窄、近端宽,这就是透视变形。人眼能自动“脑补”回矩形,但图像处理算法不会,直方图统计也更愿意处理横平竖直的图。
第二宗罪是偏色。室内白炽灯下拍出来的文档普遍发黄,荧光灯下可能发绿,手机自动白平衡还经常把某一块偏色区域当成基准,导致整页纸像罩了一层色膜。第三宗罪是底色花:装订缝阴影、手指阴影、纸张不平形成的明暗渐变,以及旧纸本身的黄斑,都会让“白纸黑字”变成“花纸灰字”。
这三件事单独看都不致命,但叠加在一起,直接打印没法看,丢给OCR软件识别率也会明显下降。所以我做的第一版工具,定位就一句话:把任意角度拍出来的纸,还原成一张干净、端正、白底的图。
1.2 为什么必须“校正+漂白”一起做
最开始我也想着先分开做,后来发现分开做容易互相拖后腿。只漂白不校正,纸张是斜的,边缘还有桌面纹理,背景估计出来的“白”就不是均匀的纸面白;只校正不漂白,纸张上的黄底阴影还在,只是从梯形变成了矩形,并不能解决打印和识别的问题。
更关键的是交互体验。你把纸拉正之后,立刻看到倾斜的文字变整齐,这时再去调漂白强度,人会对“这张图还能救到什么程度”有非常直观的判断。如果校正和漂白分别在两个窗口里做,用户来回切换,心理成本和操作成本都会翻倍。这也是我把“实时预览”作为刚需放进第一版的原因:效果好不好,手一拖就知道。
2. 校正部分的几何底层:角点顺序、透视矩阵与目标尺寸
2.1 四点顺序和坐标映射
手动校正的第一步,是让用户在预览图上依次点出纸面的四个角。顺序必须固定为左上、右上、右下、左下。这个顺序决定了后面透视矩阵的映射关系,一旦点错,比如把左上和左下搞反,图像会直接被扭成一个奇怪的平行四边形,看起来像故障了。
我第一版踩过一个很蠢的坑:在预览图上记录坐标后,直接传给透视变换,结果导出的结果和预览对不上。问题就出在坐标没有缩放。预览图是原图缩小的,比如原图宽度3600像素,预览宽度1000,缩放在0.278左右。预览图上点的坐标必须除以这个缩放系数,还原成原图坐标,再做透视变换。这个细节不写进工具说明里,使用者基本发现不了,但实现时必须处理好。
2.2 目标尺寸计算与透视变换
拿到四个角点之后,目标矩形尺寸有两种常见算法。一种是取上下两条边的平均长度作为目标宽度,取左右两条边的平均长度作为目标高度;另一种是取四条边里的最大值。我默认用最大值,因为文档矫正宁可略微放大一点,也不希望文字边缘被裁掉。
给个具体计算例子。假设四个角点坐标分别是左上(80, 220)、右上(930, 260)、右下(900, 1280)、左下(60, 1220)。上边长度是横纵坐标差的平方和开根号,算出来约851像素;下边约842像素;左边约1000像素;右边约1021像素。取最大值,目标尺寸就是约851x1021。
透视变换的几何含义是:用四个源点构建一个3x3的矩阵,把四边形映射到目标矩形。公式可以简单理解为目标坐标 = 矩阵 × 源坐标,里面包含了旋转、缩放和投影关系,用视觉库里的getPerspectiveTransform就能算出来,再用warpPerspective重采样图像。重采样时我会把边缘填充成白色,而不是默认黑色,否则拉正后的四边会出现一圈黑边,漂白时又会变成干扰。
2.3 为什么先把图拉正再谈漂白
这个顺序问题,很多人会忽略。如果先对原始倾斜图做背景估计,纸面之外的区域不全是黑色,而是包含桌面、手指、远处的环境等乱七八糟的信息。背景估计函数做的是低频模糊,这些区域会被当成“背景”,算出来的全局背景亮度完全不是纸面的真实亮度。
把图拉正之后,纸面基本铺满整个画面,四周只剩一圈白边,背景估计的输入就干净很多。这也是为什么我在工具里把校正强制放在漂白之前,而不是给用户一个可选的先后顺序。遇到过几个朋友吐槽“你的漂白算法怎么把桌面纹理都漂进来了”,最后查下来都是没有先校正。
3. 漂白部分的算法链路:灰度、背景估计、除法归一与强度混合
3.1 灰度化和全局直方图拉伸的局限
文档漂白的常规思路,是把彩色图转成灰度,然后做一次直方图拉伸,让最暗的像素接近0、最亮的接近255。听起来挺合理,但实际效果很差。原因是非均匀光照下,一张纸的不同区域有着不同的“白”:受光少的角落,白纸拍出来可能是160的灰度;而阴影边缘可能是200。如果拿全局最小值去拉伸,受光少的区域会被压暗,文字和背景的对比反而更差。
更糟的是,直方图拉伸没法区分“纸张阴影”和“文字笔画”。整张图的光照是一块渐变,不是单一偏移量,所以用一个全局线性变换解决不了。这也是为什么我会说,漂白不等于提对比度,它需要先估计出每一块区域的背景亮度,再做相对处理。
3.2 背景估计与除法归一化
我用的核心算法,可以概括成一句话:假设纸面反射率接近1,文字反射率较低,相机拍到的像素值约等于“反射率乘以光照”。那么只要估计出光照(背景亮度),把图像除以这个背景,就能得到接近反射率的归一化结果。
具体实现分三步。第一步转灰度;第二步用一个大尺寸的高斯模糊估算背景亮度;第三步把原图除以背景。代码核心是这样:
gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) ksize = max(31, int(min(gray.shape[:2]) / 10)) if ksize % 2 == 0: ksize += 1 bg = cv2.GaussianBlur(gray, (ksize, ksize), 0) normalized = cv2.divide(gray, bg, scale=255)为什么是除法而不是减法?因为光照对像素的影响近似是乘性的:光照减半,整块区域的像素大致都减半,文字和背景的相对比例不变。减法只能消去加性偏移,消除不了“亮的地方亮、暗的地方暗”这种乘性差异。除法之后,受光不足的角落,背景会恢复成接近255的白色,文字则保持相对暗度。
高斯核的大小是这里最容易踩坑的参数。核太小,文字笔画会被模糊进背景,除法之后文字变淡;核太大,纸面中间大面积阴影可能被当成背景。我通常取图像短边的十分之一,并且下限31像素。旧书那种有明显污渍的,会再加大到八分之一。
3.3 漂白强度滑杆和二值化
除法归一化只是第一步,用户还要能调节“漂多白”。我加了一个漂白强度滑杆,范围0到1,实现上是在归一化结果和纯白之间做线性混合:
strength = 0.75 white = np.full_like(normalized, 255) out = cv2.addWeighted(normalized, 1 - strength, white, strength, 0)strength等于0表示不漂白,等于1表示直接输出纯白。默认0.75左右,既能让纸张发黄的部分变成白色,又不会把文字压掉。这个滑杆本质上是给用户一个“漂白程度”的心理映射,而不是二值化开关。
如果用户想要纯黑白效果,我会额外提供二值化选项。干净打印件用Otsu全局阈值就够,手写笔记或者拍摄环境复杂的最好用自适应阈值,比如adaptiveThreshold,blockSize取31、C取15这样。但这里有个很关键的经验:手写笔记默认不要勾选二值化。二值化的笔画边缘很容易断断连连,尤其是圆珠笔写出细线条的场景,一旦断了就很难救回来。
3.4 自动白平衡:漂白里容易漏掉的一环
很多人做文档漂白只处理灰度,却忘了先把彩色图偏色问题解决。比如白炽灯下拍出来整体偏黄,如果直接转灰度,黄色背景对应的灰度值并不低,除法归一化后还是偏暗的发灰白,达不到“白底”。
我在漂白之前加了一个灰度世界白平衡:统计三个颜色通道的平均值,计算每个通道的增益,让整体平均灰接近128。偏黄的图,蓝色通道均值偏低,就会自动乘上一个大于1的增益,把蓝色提上来。这样处理完再转灰度,背景才真正是中性色。
代码示意:
b, g, r = cv2.split(warped) avg_b, avg_g, avg_r = np.mean(b), np.mean(g), np.mean(r) b = np.clip(b * (128.0 / avg_b), 0, 255) g = np.clip(g * (128.0 / avg_g), 0, 255) r = np.clip(r * (128.0 / avg_r), 0, 255) warped = cv2.merge([b, g, r]).astype(np.uint8)这套组合拳下来:白平衡负责把偏色拉回中性,背景估计负责消除亮度不均,强度混合负责把底色调白,锐化负责把文字边缘收紧。放在一起,才算完整的漂白链路。
4. 实时预览的工程取舍:预览缩略图、防抖与按需重算
4.1 预览分辨率不能跟着原图走
实时预览听起来简单,实际翻开代码就全是取舍。原图动辄三四千万像素,在预览窗口里每动一次滑块就全分辨率跑一遍透视和漂白,等待时间会按秒算,用户体验直接崩掉。
我的做法是:图片一载入就生成一张最长边1000像素左右的缩略图,所有校正点、漂白参数都在缩略图上计算,预览窗口也只显示这张缩略图的结果。1000像素对预览来说足够看清笔画细节,处理时间又能控制在几十毫秒到一两百毫秒之间。导出时再用原图和同样的参数重新算一遍,预览和导出结果在视觉上是一致的。
这里有个小坑:缩略图和原图的缩放系数必须保存下来,后面所有点坐标、尺寸都要乘回去。我见过不少半成品工具,预览效果很好,导出却偏了或者变形了,多半就是忘了还原坐标。
4.2 拖动滑块不卡顿:合并请求而不是排队请求
滑块一拖动,回调几乎是连续触发的。如果每次回调都立刻处理,UI线程会被密集的计算阻塞,滑块拖起来像在泥里划船。更复杂的是,如果处理任务排成队列,用户已经拖到最终位置时,前面几十个中间状态还会挨个执行,预览永远在追参数,看着非常难受。
正确做法是“合并请求”:无论回调触发了多少次,永远只保留最新的一组参数,然后等约200毫秒的防抖时间,再执行一次处理。如果处理过程中又有新参数进来,就放弃当前结果,直接用最新参数重新算。简单说就是只处理最新,不排队处理全部。这样用户连续拖参数时,预览最多滞后一两帧,但永远指向最终状态。
防抖这块,用时间戳比用计数器靠谱。滑块回调记录last_change = now(),工作线程判断now() - last_change > 0.2才真正开工;开工过程中再检查参数有没有变,变了就重来。实现不难,收益却非常明显。
4.3 从预览到导出:参数复现与格式选择
导出的过程,本质上是把预览时用到的所有参数,在原始分辨率下重新执行一遍。透视角点用缩放系数映射回原图坐标,漂白强度、白平衡开关、二值化模式全部沿用。为了保证导出质量,透视重采样插值可以换成高精度的三次插值,而不是预览用的线性插值;最终保存时,我默认导出PNG无损格式,因为反复编辑和印刷,PNG比JPG更稳。
还有一个小细节:导出的目标矩形四周我会额外留出5到10像素的白色边距,避免透视拉正后文字贴到边界。这5像素在预览上几乎看不出来,但打印时能避免裁切把边缘文字切掉。别小看这种小问题,很多“怎么导出后字没了”的反馈,最后都出在这种细节上。
5. 实测翻车记录与一套能直接上手的默认参数
5.1 角点顺序点错,预览直接变成抽象画
我最开始没给用户编号提示,四个点靠猜测,结果不少朋友试点第一下就翻车:拉正后的图变成了奇怪的扭曲轮廓。查来查去就是顺序问题。后来我在界面上把四个角标上序号,并且把已选点画出来,顺序错也能立即发现。如果你自己写工具,这一点一定要做,别省。
另外,如果纸张只占了画面一小部分,周围全是背景,四点校正的效果会很差。因为透视矩阵会被大面积背景带偏。这种图,先裁切到纸张附近再校正会稳很多。
5.2 漂白强度拉满,文字会“骨折”
有个高频问题:把漂白强度拖到接近1,整个页面确实白了,但文字细笔画也跟着变淡,甚至断成一段一段。原因前面说过,强度混合本质是让所有像素向白色靠拢,文字那些相对浅的灰边,比深色主干更早被“漂没”。
遇到这种图,我的处理习惯是先把强度降到0.5到0.6,宁可让纸面保留一点点底色,也要保证笔画完整。如果确实需要纯白底,再配合二值化并且适当提高对比度。实测下来,打印体文字对高强度的耐受度比手写体高很多,手写笔记请克制。
5.3 旧书、复印件和彩色印章不能一套参数通吃
旧书纸张本来就不是纯白,泛黄是有岁月质感的。你把强度拉到0.9,它会变成一张信息量尽失的惨白纸,看着干净,但原来的层次感没了。我的经验是旧书翻拍用0.5以下的强度,先做白平衡就好。
复印件和激光打印件常见灰底网纹,如果开了二值化,网纹会被放大成摩尔纹。这时候建议强度控制在0.5,并且关掉高倍锐化,别追求“绝对干净”。
彩色印章更是典型:如果文档里有红章,灰度漂白会把红章变成灰色,甚至直接消失。所以工具里必须保留“仅校正不漂白”模式。我先开始没做这个开关,后来一个朋友要归档带红章的文件,才发现自己漏了一个刚需功能。
5.4 一套能直接抄的默认参数
| 场景 | 自动白平衡 | 漂白强度 | 清晰度 | 二值化 | 备注 |
|---|---|---|---|---|---|
| 打印黑白文档 | 开 | 0.8 | 0.3 | 可选 | Otsu即可 |
| 手写笔记 | 开 | 0.6 | 0.2 | 关 | 保护笔画 |
| 手机拍旧书 | 开 | 0.4 | 0.1 | 关 | 保留质感 |
| 发票/带边框表格 | 开 | 0.7 | 0.2 | 关 | 防止表格线断裂 |
| 带红章的文件 | 开 | 0(仅校正) | 0 | 关 | 保留印章颜色 |
| 复印件灰网纹 | 开 | 0.5 | 0 | 关 | 避免摩尔纹 |
这张表是我反复试出来的起点,不是绝对真理。不同手机的镜头、不同角度、不同光线的照片,最优值都会漂移。你可以把这套参数当复位基准,遇到具体图再微调。
最后分享一个操作习惯:同一批材料,我会先挑一张光线最差的开预览调参数,确定漂白强度后,剩下的图只需要重新点四个校正角点,漂白参数基本不用改。这套流程能把我处理一摞A4扫描件的时间压缩到每张十几秒。图片校正漂白工具的价值,不在算法多酷,而在减少重复劳动;矫正实时预览做好之后,我几乎不再需要反复保存、打开、检查了。
我个人的体会是:做这类工具,最容易被低估的不是算法,而是交互反馈。一张图从“歪黄糊”到“端正白净”,中间要解决的问题不少,但只要实时预览和参数滑杆到位,用户自己就能找到合适的中间值。如果你也想复现一个,不用一上来追求各种高级深度学习和大模型增强,先把背景估计、透视校正、实时预览这三件事跑通,已经能覆盖日常90%的文档需求。剩下的10%,比如旧书修复、印章保留,都是在这些基础上慢慢补出来的。希望这些踩坑经验能让你少走点弯路。