OpenCV估倾斜角,歪3°的页面被算成了93.37°

2 阅读14分钟

扫描件进 OCR 之前先估倾斜角再转正,是预处理里常见的一步,代码短到十来行就能抄完。我拿一页英文扫描页人为转了 3° 再用网上能搜到的一种老写法去估角,打印出来是 93.37°。程序没报错也没告警。要是后面的旋转用的是这个角度,页面会直接横过来。

问题出在 cv2.minAreaRect 返回的角度区间上。不同 OpenCV 版本的约定不一样。老代码里那行换算照的是旧约定。下面把出错的过程、改法和核对用的几组数都摊开来讲。样本和环境写在文末。数字只代表这一页、这台机器上的单次运行。实测到的和推断出来的我分开写,推断集中放在后面单独标出。

倾斜伤不伤识别要看接的识别器

先说为什么要费这个劲去估角。我测了三种识别器在一台 Apple M4 的 Mac 电脑上对倾斜的反应,差得很远。先看 Tesseract 这一种。Tesseract 5.5.3 在这页英文扫描上不歪时字符准确率是 98.9%。歪 3° 掉到 98.1%,歪 5° 掉到 96.2%。只做纠斜就能把 5° 那张拉回 99.0%。加上 σ=25 的高斯噪声以后 5° 不处理只有 92.9%,纠斜后回到 97.9%。

再看 macOS 自带的文字识别。它对 0~5° 基本没反应。中文页一共 557 个字,各个角度都只错 4~8 个。最敏感的是浏览器端的 PP-OCR。我用的是图映 ImgIng imging.cn/ 在桌面浏览器里的 PDF 内容提取并选了默认专业档。中文页不歪时它只错 2 个字。歪 3° 以后行序被打乱,编辑距离变成 175 并丢了 31 个字。歪 5° 编辑距离是 362 并丢了 275 个字。原文 14 行只剩一半左右,界面照样显示处理完成。同一页加上噪声再歪 3° 的版本,先去噪再纠斜再做 Otsu 后,macOS 自带识别错 6 个,图映回到错 2 个。英文页同样处理后 macOS 自带错 1 个,Tesseract 错 13 个,图映错 2 个。这几个数用的是 JPEG 页面图,和下面 Tesseract 网格的 PNG 输入不是同一批图,不能放在一起比。

Tesseract 那组我把「不处理」和「只纠斜」两行并排放在这里。英文页用的是 PNG 输入,单位是字符准确率(%)。

条件0°1°3°5°
无噪 · 不处理98.999.598.196.2
无噪 · 只纠斜98.898.798.999.0
高斯 σ25 · 不处理97.996.895.292.9
高斯 σ25 · 只纠斜98.499.098.097.9

这张表里有一处我到现在也没想明白。无噪 1° 那张不处理是 99.5%,纠斜后反而只有 98.7%。0° 那张也从 98.9% 变成了 98.8%。原因我没查,也可能只是单次运行的波动。歪得少的页面本来就没多少可救的。到 3° 以上纠斜的好处才明显盖过这点损失。

纠斜值不值得做要看后面接的是谁。接 macOS 自带识别的话这一步可有可无,接 Tesseract 或浏览器端 PP-OCR 就得把角度估准。估错了比不估更糟,因为估错的角度会把一张本来能用的页面转坏。被法务盯过三个月之后,扫描合同这类文件我能不出本机就不出本机。能选的识别器本来就少,预处理这一步更得自己在本地把住。

老换算在新版本上多减了90度

下面是这次跑的完整代码。两种估角办法放在一起,一个是 minAreaRect 另一个是投影法。第 6 行是改过的折叠写法。原来那行老换算放在后面说。

import cv2, numpy as np, sys
g = cv2.imread(sys.argv[1], 0)
# 1) minAreaRect:所有文字像素的最小外接矩形
b = cv2.threshold(g, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
(_, _), (w, h), a0 = cv2.minAreaRect(cv2.findNonZero(cv2.medianBlur(b, 3)))
a = (a0 + 45) % 90 - 45       # 不同 OpenCV 版本角度区间不同,统一折到 [-45, 45)
print(f"minAreaRect  估计倾斜 {-a:.2f}°  (原始返回 {a0:.2f}°)")
# 2) 投影法:缩小 1/4,在 ±7° 内每 0.1° 转一次,取行投影方差最大的角度
s = cv2.threshold(cv2.resize(g, None, fx=0.25, fy=0.25), 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
H, W = s.shape
def score(t):
    M = cv2.getRotationMatrix2D((W / 2, H / 2), t, 1)
    return np.var(cv2.warpAffine(s, M, (W, H)).sum(1, dtype=np.float64))
best = max(np.arange(-7, 7.01, 0.1), key=score)
print(f"投影法       估计倾斜 {-best:.2f}°")
M = cv2.getRotationMatrix2D((g.shape[1] / 2, g.shape[0] / 2), best, 1)
cv2.imwrite("page_fixed.png", cv2.warpAffine(g, M, g.shape[::-1], flags=cv2.INTER_CUBIC, borderValue=255))
$ python3 deskew.py page_skew3.jpg
minAreaRect  估计倾斜 3.37°  (原始返回 -3.37°)
投影法       估计倾斜 3.30°

minAreaRect 这一路的思路很朴素。先用 Otsu 反相让文字变成白色前景。再做一次 3×3 中值滤波去掉页面上零星的噪点。然后把所有前景像素交给 findNonZero 求一个能全部包住它们的最小旋转矩形。矩形转了多少度页面就歪了多少度。打印时对角度取负号是为了和造样本时转的方向同号。转 3° 的页面读出来就是 3 点几。

外接矩形要包住每一个前景像素,所以这个办法对前景里混进了什么非常敏感。我的样本事先裁掉了页边和底部的数字化水印。留下的前景只有正文、页眉 THE TIME MACHINE 和页码 8 这些横排文字。造倾斜样本时我以页面中心旋转,画布扩大一圈并在四角填白色。反相以后白色成了背景,不会混进参与计算的前景像素里。

把第 6 行换成网上能搜到的老写法 if w < h: a -= 90 以后,同一张图输出的是 minAreaRect 估计倾斜 93.37°。把原始返回值和宽高一起打印出来看就清楚了。这台机器上 OpenCV 5.0.0 给的角度是 −3.37°,矩形宽 1202、高 2033。宽小于高,老换算于是又减了 90° 变成 −93.37°。取负号以后打印出来的就是 93.37°。多减的就是这 90°,而这 90° 在打印出来的那一行里看上去和一个正常的角度没有任何区别。从哪个版本开始改的我没把几个版本都装一遍去对,这里不下结论。要是你的版本返回的是 86.63° 这种正角度,宽高也会跟着互换,老换算会不会出错得看那个版本怎么配对,我没法替你判断。

更麻烦的是这个错误在流水线里不容易被发现。流水线里如果只有 minAreaRect 一种估法,下游就没有哪一步会去问这个角度合不合理。旋转函数接到 93.37 就会老老实实地把页面转 93.37°。日志里最多多出一行谁也不会去看的数字。这行换算的影响面是经过它的所有页面。依赖一升级它们会一起变样。

拿 93.37° 去转一页竖长的扫描件,页面会整个横过来。上面这段代码为了短而让输出尺寸和原图一样大。页面横过来以后大半内容会被裁到画布外面去。这张转坏的图我没送进识别器,识别结果会怎样我不猜。能确定的是它不再是一张能用的页面,而整条流水线一行报错都没有。

角度统一折到正负45度之间

改法只有一行。就是代码第 6 行那一句折叠。不管 OpenCV 返回的是哪个区间都先加 45、对 90 取模、再减 45。结果一定落在 [-45, 45) 这个区间里。

一个矩形转 90° 以后还是它自己,只是宽和高互换了位置。minAreaRect 返回的角度本来就只在 90° 以内才有意义。−3.37° 和 86.63° 描述的是同一个框,区别只在谁当宽谁当高。扫描页的倾斜通常只有几度。折到 ±45° 之内等于告诉代码只信小角度那一种解释。代入这次的数,−3.37 加 45 是 41.63。对 90 取模还是 41.63,再减 45 就回到了 −3.37。取负号打印出的 3.37° 和投影法的 3.30° 对上了。

这一行既不依赖宽高谁大谁小也不依赖版本。它只假设页面的真实倾斜在正负 45° 以内,这个假设对正常放进扫描仪的纸张基本成立。以后升级 OpenCV 时也不用回头再改这一行。它能写这么短靠的是 Python 的 % 对负数也返回非负结果,-50 % 90 得 40。把这行搬到别的语言里要先查那门语言的取余规则,比如 JavaScript 的 % 结果跟被除数同号,-50 % 90 得 −50,照搬可能折不进 [-45, 45),得写成 ((a0 + 45) % 90 + 90) % 90 - 45 再套一层取模。要是打算在前端给用户看转正后的预览,这一处要单独核一遍。这条是语言层面的提醒,没在本次实测里验证。

投影法那一路其实也有方向问题,只是被代码结构藏住了。getRotationMatrix2D 的角度参数为正时表示逆时针旋转。score 在缩小图上试了一圈角度。方差最大的 best 就是把页面转正要转的量。最后对原图直接用 best 去转,打印时再取负号表示页面原本歪了多少。缩小图和原图的旋转中心不同,但旋转角度和缩放比例无关。因此缩小图上找到的角度可以原样用在原图上。

缩小到四分之一是为了让 141 个候选角度都转一遍时算得动。代价是角度精度只能到候选的那一档。候选本身按 0.1° 一档排,所以投影法给出的 3.30° 只能读成「3.3 附近」。它和 minAreaRect 的 3.37° 只差 0.07°。这还不到一档,谈不上谁更准。

两种估法在这页上差不到0.2度

光一张图对上还不算数。我把同一页英文扫描分别转了 0°、1°、3°、5° 和反向 3° 让两种办法各估一遍,单位都是度。

转了多少投影法minAreaRect(折叠后)两者之差
00.30.370.07
11.21.390.19
33.33.370.07
55.35.410.11
−3−2.7−2.590.11

正向四个角度差 0.07~0.19°,反向差 0.11°。差得最多的是 1° 那张,minAreaRect 比投影法多估了 0.19°。为什么偏偏是这一张我没去查。英文样本只有这一页,也说不好是不是巧合。每个数都比我转的角度多出 0.3° 上下。这部分不是误差。原页扫描时本身就带着约 0.3° 的倾斜,0° 那张两种办法都测到了。中文页我也用投影法跑了一遍。0/1/3/5° 估出 −0.2~−0.3 / 0.8 / 2.8 / 4.8°。原页自带大约 −0.2~−0.3°,减掉以后同样对得上。

投影法估角的原理和 minAreaRect 完全不一样。它把二值图缩到四分之一后在 ±7° 内每隔 0.1° 转一次。每转一次就把每一行的白色像素加起来再算这些行和的方差。页面转正的那一刻文字行和行间空白分得最开。这时文字行的行和很大而行间空白的行和接近零,方差也就最大。两种办法用到的图像信息完全不同。一个看全部前景的外轮廓,另一个看行与行之间的明暗交替。两者能互相对上,比单看哪一个都踏实。

加了噪声的条件下我只核对过投影法这一种。在高斯噪声和椒盐噪声的版本上投影法估出的角度和不加噪时完全相同。minAreaRect 在噪声下估出的角度我没有单独记下来,这里就不给数了。

实际纠斜那组实验用的是投影法的角度。以页面中心反向旋转时用三次插值和白底,画布扩大一圈,不会像上面那段代码那样裁掉四角。minAreaRect 留在流水线里专门做交叉核对。我打算上线的写法分三道拦截。第一道是折叠后的角度绝对值超过 10° 先当异常,扫描页歪到这个程度更像是放反了或拍歪了,不该交给旋转去修。第二道是投影法的 best 正好落在 −7° 或 7° 的搜索边界上也当异常,因为真实角度可能在搜索范围之外。第三道是两个角度差超过 0.5° 就不转,原图照送并记一条日志等人看。

0.5° 和 10° 这两个阈值是我按这几张图拍的,没拿大批样本标定过。换成你自己的扫描件可以先跑一批看两者平时差多少再定。纠斜在流水线里放哪一步也有讲究。同一组实验里页面带椒盐噪声时,先纠斜再去噪的准确率只有 91.5~94.4%,先去噪再纠斜是 98.6~99.1%,所以估角之前先把点状噪声去掉。

超过45度的倾斜折叠后认不出来

折叠写法有两种情况自己解决不了。第一种是真实倾斜超过 45° 的页面,比如扫描时整页横着放了。minAreaRect 折完会给一个很小的角度,投影法在 ±7° 内也找不到真正的答案。两者还可能刚好对上,交叉核对也拦不住。第二种是整页上下颠倒转了 180° 的扫描件。矩形和行投影都看不出来,两种办法估出的角度都可能接近 0。这两类问题都要靠单独的方向检测来处理。Tesseract 自带的 OSD 按说能判断页面朝向,但这次没测,效果我说不了。

这次用的样本本身也有不少限制。这次的两页都是字号适中、只有一栏的清晰出版物扫描。倾斜和噪声是程序加的,不是真的扫歪、真的脏。多栏、带表格、页边有大片阴影的扫描件这次都没测,150 DPI 以下的低分辨率扫描和多页 PDF 也没测。估角办法只比了这两种,霍夫直线估角、Leptonica 自带的纠斜和 Tesseract 的版面参数都没试,它们和这两种比谁更稳我说不了。手机拍的照片带透视变形,单靠一个旋转角度是纠不过来的。

上面有几处是推断而没有实测支撑,集中列在这里。第一处是老换算在它当初对应的 OpenCV 版本上应该是对的,只是宽高和角度的配对约定后来变了。第二处是四角要是填成黑色,反相后那四个三角形会全成前景,外接矩形会变成整张画布的外框。第三处是 minAreaRect 遇到噪声时残留的孤立噪点和扫描边框会把外接矩形撑大、带偏角度。真实扫描件上的盖板黑边、装订孔和页边阴影也属于这一类。第四处是椒盐噪声下先纠斜会坏事的原因,可能是三次插值把孤立噪点糊成了几个像素大的灰斑,3×3 中值去不掉。这几条我都没单独做对照实验,引用的时候请当成推断而不是测出来的结论。

工程上最怕的是升级依赖时行为悄悄变了却没人发现。我更愿意把这件事写成一条回归用例放进 CI 里。仓库里放一张自己造的样本,也就是一页清晰的扫描件以页面中心转 +3° 并用白底填充。用例跑一遍估角,断言两种办法都落在 2.5~3.5° 之间且两者之差小于 0.5°。再放一张转 −3° 的样本确认符号没有反。不管是 OpenCV 换了大版本还是有人顺手把那行换算改回老写法,CI 都会先红。这条用例还没在 CI 里跑过,区间是按上面几张图的数留的余量,换了样本要重新定。

手上已经有这段估角代码的,可以先造一张已知角度的页面。自己转个 3° 后打印 minAreaRect 的原始返回值和宽高,看看你那个版本返回的是什么。看完再决定那行换算留不留,或者干脆换成折叠写法。

样本:英文页取自 H. G. Wells《The Time Machine》1895 年版第 8 页,扫描件来自维基共享资源,公有领域。中文页取自国家语言文字工作委员会《简化字总表》1986 年新版说明页,来自 Internet Archive,公有领域标记。倾斜和噪声版本是自制的。环境是 Apple M4 的 Mac 电脑、Python 3.9、OpenCV 5.0.0 和 Tesseract 5.5.3。