扫描件这东西,相信不少朋友都碰过。尤其是那些从老式复印机或者扫描仪里出来的PDF或图片,经常歪歪扭扭,有的倾斜几度,有的甚至倒了十几度。人眼看起来倒还好,但要是做批量处理,比如做OCR文字识别、归档、打印排版,倾斜的页面会让识别准确率直线下降,打印出来也特别别扭。所以,自动把图片“摆正”是个很常见的需求。今天咱就用Python里的Pillow库,聊一个非常经典的思路:基于投影轮廓来做自动旋转校正。这个方法不依赖外部AI模型,也不靠复杂的特征点匹配,纯粹从像素分布出发,简单却常常很有效。

一、应用场景:什么时候需要这种校正

先说说现实中的场景。很多单位的档案室、财务部,或者做文档数字化的小团队,每天要处理几百上千张扫描件。这些扫描件来源五花八门,有的是人工一页一页放到扫描仪里,有的用手机拍的书页照片转成灰度图,甚至还有传真件。倾斜原因也很多:纸张放歪了、进纸器卡纸导致偏斜、拍照时角度没端平。如果只靠人工在Photoshop里一张张转正,效率太低。这时候,一段自动校正的代码就能派上大用场。

需要注意的是,这种基于投影的方法最适合处理“文本内容为主”的文档,比如报纸、合同、打印文稿、手写表格等。因为文本行有很明显的水平或垂直走向,倾斜后投影轮廓的特征非常清晰。但如果是一张风景照、一幅油画,或者有大面积纯色背景的图片,投影轮廓就不太敏感了,效果也会大打折扣。所以,用之前得先想想你的扫描件是什么类型。

二、核心思路:投影轮廓是什么

咱们想象一下,一张文档图片如果完全摆正,黑色的文字行应该基本水平,行与行之间是白色的空隙。如果我们把图片的每一行像素都统计一下“黑色像素”(或者深色像素)的数量,然后画出一条柱状图,这个柱状图就是“水平投影轮廓”。文字多的行,柱子高;空白行,柱子矮甚至为零。

图片一旦倾斜,原本水平的文字行就会斜着走。这时候再做水平投影,每一行扫过去的黑色像素数量就会变得“糊成一团”,原本清晰的高峰和低谷会被抹平,轮廓的“对比度”迅速下降。换句话说,投影轮廓的“尖锐程度”和图像的倾斜角度是直接相关的。当倾斜角度为0时,投影轮廓最“棱角分明”;角度越偏,轮廓越模糊。那么,我们只要尝试一系列角度,分别旋转图片并计算投影轮廓的某个“锐度指标”,找到指标最好的那个角度,就是我们要的校正角度。

同理,垂直投影也是这个道理。文本行一般水平,所以水平投影最容易识别。但如果文档里有竖排文字,或者图片本身旋转了90度,那就需要同时考虑水平和垂直两个方向。咱们先从最简单的水平情形说起。

三、技术栈与环境准备

接下来的代码示例全部使用 Python 的 Pillow 库,以及标配的 numpy 库来做数值计算。Pillow 负责读取图片、旋转图片;numpy 负责高效地处理像素矩阵。如果你还没有安装,可以先在终端执行下面的命令:


# 安装 Pillow 和 numpy
pip install Pillow numpy

如果是在 Jupyter Notebook 环境,也可以在代码前加上 !pip install Pillow numpy。注意版本兼容性,Pillow 8.0 以上基本都没问题。

四、详细实现步骤

4.1 读取图片并转成灰度图

我们第一步先把彩色扫描件读进来,转成灰度图。为什么要转灰度?因为后续统计黑色像素只需要亮度信息,彩色信息反而是噪声。转灰度之后,每个像素的取值是0到255,0是纯黑,255是纯白。

下面是一段示例代码。假设我们有一张歪了几度的扫描件文件 scan_sample.jpg


# 技术栈:Python + Pillow + numpy
from PIL import Image
import numpy as np

# 打开图片文件
img = Image.open("scan_sample.jpg")

# 转成灰度模式
gray_img = img.convert("L")

# 转为numpy数组,方便后续数值计算
gray_arr = np.array(gray_img)

# 打印一下尺寸和数据类型,确认转换成功
print("图片尺寸:", gray_arr.shape)   # 输出 (高度, 宽度)
print("像素取值范围:", gray_arr.min(), gray_arr.max())

注释做得比较详细。这里要注意,gray_arr 的 shape 是 (高, 宽),后续计算投影时,水平投影就是沿宽度方向求和,垂直投影就是沿高度方向求和。

4.2 二值化:把文字和背景分开

直接统计灰度值当然可以,但文字不一定纯黑,背景也不一定纯白,可能带点浅灰或纸张底色。为了更稳定地表现“哪里是文字”,最好做个二值化处理。常用的是固定阈值法,或者 Otsu 自适应阈值。Pillow 自带 point 方法可以快速做阈值分割。


# 技术栈:Python + Pillow + numpy
# 继续使用之前的 gray_arr

# 设定一个阈值,比如 150,大于该值算白色背景,小于等于算黑色文字
threshold = 150

# 生成二值数组:文字部分为1,背景部分为0
binary_arr = (gray_arr < threshold).astype(np.uint8)

# 看看黑色像素占比,判断阈值是否合理(一般文档在5%~40%之间)
black_ratio = binary_arr.mean()
print(f"黑色像素占比: {black_ratio:.2%}")

如果扫描件有严重的阴影或者泛黄,固定阈值可能效果不佳。这时候可以用 numpy 和 PIL 配合实现 Otsu,不过咱们这例子先不展开,因为核心是投影思想,阈值选个合理的值就够用。

4.3 计算投影轮廓并评估“锐度”

接下来是重头戏。对二值数组,水平投影就是每一行所有列求和,得到一个一维数组,长度等于图片高度。垂直投影同理。

锐度用什么指标?最简单直观的是“方差”。理想情况下,文字行的投影值很大,空白行的投影值接近0,分布两极化,方差就大。倾斜之后,投影值在行与行之间变得平均,方差就小。所以我们可以用方差,或者标准差作为锐度评价。也可以用“峰值数”,数一数投影中明显波峰的个数,但方差实现最简单,效果也不错。


# 技术栈:Python + Pillow + numpy

def compute_projection_score(binary_array):
    """
    计算二值数组的投影轮廓得分。
    这里使用水平投影的方差作为锐度指标。
    """
    # 水平投影:按行求和,结果是长度为高度的一维数组
    horizontal_proj = binary_array.sum(axis=1)
    
    # 计算方差,方差越大,说明投影轮廓越“尖锐”
    score = horizontal_proj.var()
    return score

# 测试一下原始图片的得分
score_original = compute_projection_score(binary_arr)
print(f"原始图片投影得分: {score_original:.0f}")

你可能觉得这么简单就完了?别急,旋转图片后,二值数组的尺寸可能会变化,而且旋转会引入插值,导致原本只有0和1的数组变成0到1之间的浮点值。所以我们需要稍微调整一下流程,在旋转后重新二值化。另外,为了减少计算量,可以先缩小图片,或者只取中间一部分区域来算投影。后面我会讲优化。

4.4 搜索最佳旋转角度

现在最关键的是怎么搜索。最无脑但也最可靠的方式是:从 -10 度到 +10 度,每 0.5 度试一次,旋转图片并计算得分,找到得分最高的角度。为什么范围选 ±10?因为一般扫描件歪斜不会超过10度,偶尔歪得厉害点,先手工粗看再扩大范围也行。步长0.5度,对于OCR来说精度已经足够,如果你想要更高精度,可以增加步长,但计算量会成倍增长。

下面给出完整搜索函数。需要注意,Pillow 的 rotate 方法默认不会扩大画布,如果我们使用 expand=True,旋转后图片尺寸会变大,背景为黑色。这个黑色背景会影响投影统计,所以我们旋转后要把背景填充为白色,或者在旋转前先把图片的边角填充为白色背景。更保险的做法是:旋转时用 fillcolor=255 将填充色设为白色(灰度图中255是白色)。


# 技术栈:Python + Pillow + numpy

def find_best_rotation(gray_img, candidate_angles, threshold=150):
    """
    给定灰度图,尝试一系列角度,返回最佳角度和对应得分。
    
    参数:
        gray_img: PIL 灰度图
        candidate_angles: 角度列表,比如 range(-10, 11, 1)
        threshold: 二值化阈值
    """
    best_angle = 0
    best_score = -1
    
    for angle in candidate_angles:
        # 旋转灰度图,expand=True 会扩大画布,fillcolor=255 让空白区域变白色
        rotated = gray_img.rotate(angle, expand=True, fillcolor=255)
        
        # 转换成numpy数组并二值化
        arr = np.array(rotated)
        binary = (arr < threshold).astype(np.uint8)
        
        # 计算得分
        score = binary.sum(axis=1).var()
        
        # 记录最优
        if score > best_score:
            best_score = score
            best_angle = angle
            
        # 打印一些调试信息(可注释掉)
        print(f"角度 {angle} 得分 {score:.0f}")
    
    return best_angle, best_score

# 生成候选角度:-10 到 10,步长 1 度
candidate_angles = range(-10, 11, 1)

best_angle, best_score = find_best_rotation(gray_img, candidate_angles)
print(f"最佳角度: {best_angle},得分: {best_score:.0f}")

运行这段代码,你会看到每个角度的得分。通常最佳角度会有一个明显的峰值。但步长1度可能不够精细,可以再做一轮精细搜索:在最佳角度的前后各1度范围里,以0.1度步长重新搜索。下面给出精细化示例。


# 技术栈:Python + Pillow + numpy
# 在第一次粗搜结果基础上精细化

# 假设上一个单元格已经得到 best_angle(比如 3)
# 生成精细候选:best_angle-1 到 best_angle+1,步长 0.1
fine_angles = []
current = best_angle - 1.0
while current <= best_angle + 1.0:
    fine_angles.append(round(current, 1))
    current += 0.1

# 重新计算
best_angle_fine, best_score_fine = find_best_rotation(gray_img, fine_angles)
print(f"精细最佳角度: {best_angle_fine},得分: {best_score_fine:.0f}")

4.5 执行最终旋转并保存

找到了角度,咱们还得把这角度“取反”再旋转回去。为什么?因为刚才我们搜索的角度其实是通过旋转图片来模拟“倾斜修正后的状态”。如果原始图片向右倾斜了3度,我们旋转到-3度时得到最佳投影,那么最终校正时就应该把原始图片旋转+3度还原。这里有个符号问题,最稳妥的办法是:把原图直接旋转刚才找到的“最佳角度”的相反数。但在上面搜索时,我们是对原图尝试不同角度旋转后计算投影,如果某个正角度让投影最清晰,说明原图本身往反方向歪了,修正时直接用这个正角度旋转原图就行。举个例子:原图向左歪了2度,那么当我们对原图旋转+2度时,文字正好水平,投影最清晰。所以原图旋转+2度就是校正结果。因此,最佳角度直接用于旋转原图,不需要取反。验证一下:原图逆时针歪了(左低右高),旋转+2度(逆时针转)正好摆正,对。所以上面 best_angle_fine 直接传给 rotate 就可以。


# 技术栈:Python + Pillow + numpy
# 得到最佳角度后,旋转原始彩色图或灰度图均可
rotated_final = gray_img.rotate(best_angle_fine, expand=True, fillcolor=255)

# 保存结果
rotated_final.save("scan_sample_corrected.jpg")

# 你可以顺带看看新尺寸和原尺寸的区别
print("原图尺寸:", gray_img.size)
print("校正后尺寸:", rotated_final.size)

注意:旋转后画布会变大,四角出现白色区域。如果你有后续OCR流程,这些白色区域通常不影响识别。如果你希望输出尺寸和原始一样,可以再用 Image.crop 去掉白边,但这一步会考验你的裁剪算法,咱们暂不深入。

五、优化技巧:不把时间浪费在无谓的计算上

上面的代码可以工作,但效率不高。因为每尝试一个角度,都要旋转整张图并生成完整的投影。拿一张A4扫描件来说,300dpi分辨率下可能接近2500x3500像素,旋转一次就要处理几百万像素,再循环几十次,电脑风扇直接起飞。优化思路有三个:

5.1 缩小图片提高速度

我们需要的只是“找到角度”,不需要高分辨率下的细节。可以先把图片缩小到宽度500像素左右,在缩略图上搜索角度,找到后再用原始高分辨率图片做最终旋转。


# 技术栈:Python + Pillow + numpy
# 缩小图片加速搜索
scale_width = 500
ratio = scale_width / gray_img.width
small_size = (scale_width, int(gray_img.height * ratio))
small_gray = gray_img.resize(small_size, Image.LANCZOS)

# 在缩小图上搜索角度
best_angle_small, _ = find_best_rotation(small_gray, candidate_angles)
print(f"缩略图上找到的角度: {best_angle_small}")

# 然后可以用这个角度作为中心,在原图上做精细搜索
fine_start = best_angle_small - 1.0
fine_end = best_angle_small + 1.0
# 生成0.1步长的角度列表
fine_candidates = [round(fine_start + i * 0.1, 1) for i in range(21)]

# 在原图(或再缩一半的图上)搜索
best_angle_final, _ = find_best_rotation(gray_img, fine_candidates)
print(f"最终角度: {best_angle_final}")

5.2 只取图像中央区域

扫描件边缘常有阴影、装订孔或黑边,这些都会干扰投影。裁剪出中央约70%的区域,既能减少计算量,又能规避边缘干扰。


# 技术栈:Python + Pillow + numpy
# 裁剪中央区域
width, height = gray_img.size
crop_box = (
    int(width * 0.15),
    int(height * 0.15),
    int(width * 0.85),
    int(height * 0.85)
)
central_region = gray_img.crop(crop_box)

用这个 central_region 做角度搜索即可。

5.3 更聪明的搜索策略:从粗到细

前面已经演示了两阶段搜索:粗步长1度,细步长0.1度。如果文档歪斜角度超过15度,可以先把范围扩大到 ±45度,步长5度,快速找到大致方向,再逐步精细化。这个策略类似“金字塔搜索”,在工程上很常见。

六、实际例子:跑通全流程

下面我把整个流程放进一个完整脚本,方便你直接复制运行。假设输入文件是 scan_oblique.png


# 技术栈:Python + Pillow + numpy
from PIL import Image
import numpy as np

def binarize(img_gray, threshold=150):
    """把PIL灰度图转为二值numpy数组,文字为1,背景为0"""
    arr = np.array(img_gray)
    return (arr < threshold).astype(np.uint8)

def projection_score(binary):
    """水平投影方差作为锐度指标"""
    horizontal_proj = binary.sum(axis=1)
    return horizontal_proj.var()

def find_angle(img_gray, angles, threshold=150):
    """在给定角度列表中找到最优角度"""
    best_a, best_s = 0, -1
    for a in angles:
        rotated = img_gray.rotate(a, expand=True, fillcolor=255)
        binary = binarize(rotated, threshold)
        s = projection_score(binary)
        if s > best_s:
            best_s = s
            best_a = a
    return best_a, best_s

# 主流程
if __name__ == "__main__":
    # 打开原始图片
    original = Image.open("scan_oblique.png")
    gray = original.convert("L")
    
    # 缩小到宽500,加快粗搜
    scale = 500 / gray.width
    small_gray = gray.resize((500, int(gray.height * scale)), Image.LANCZOS)
    
    # 粗搜角度 -10 到 10,步长2
    coarse_angles = list(range(-10, 11, 2))
    coarse_angle, _ = find_angle(small_gray, coarse_angles)
    print(f"粗搜角度: {coarse_angle}")
    
    # 精细搜索:以粗搜角度为中心,步长0.2,范围正负1度
    fine_angles = []
    start = coarse_angle - 1.0
    for i in range(11):  # 覆盖 -1.0 到 1.0,步长0.2,共11个点
        fine_angles.append(round(start + i * 0.2, 1))
    fine_angle, _ = find_angle(gray, fine_angles)  # 在原图或半尺寸图上搜索
    print(f"精细角度: {fine_angle}")
    
    # 最终校正并保存
    corrected = gray.rotate(fine_angle, expand=True, fillcolor=255)
    corrected.save("scan_oblique_corrected.png")
    print("校正完成!")

这个脚本可以直接在命令行用 python script.py 运行。注意不要把文件名写成 PIL.py,避免把库名覆盖。

七、技术优缺点分析

7.1 优点

  • 实现简单,代码量少,没有复杂的数学原理和第三方依赖,仅需Pillow和numpy。
  • 速度快,特别是用缩略图+粗到细搜索后,通常几十毫秒就能找到角度。
  • 对文本型文档效果极好,因为文字行的投影特征非常稳定。
  • 不需要训练数据,也不需要标注,拿来就用。

7.2 缺点

  • 对纯图像、照片、无规则图形不敏感,投影方差可能变化不大,导致找不到正确角度。
  • 对多栏布局、图文混排、表格复杂的文档,单一水平投影可能不够,因为水平方向的文字行可能被表格线、插图打断。不过只要整体倾斜,投影方差仍然有效,只是最佳角度的峰值不如纯文本明显。
  • 对倾斜角度接近0度时,如果步长过大,可能错过0度附近的精确峰值。解决方法是增加搜索密度。
  • 旋转插值会带来轻微模糊,可能导致投影指标在小角度范围内出现平台甚至噪声。解决方法是多试几次或同时用垂直投影辅助,取两者分数之和。

八、注意事项

第一,阈值的选择很关键。如果你的扫描件是深色背景、浅色文字,二值化逻辑需要反转。上面的代码假设“文字暗、背景亮”,如果遇到反色文档,阈值条件要改成 arr > threshold。第二,rotate 方法默认的最近邻插值或双线性插值会影响边缘像素,建议搜索阶段用 Image.BILINEAR,最终旋转用 Image.BICUBIC 以得到更平滑结果。第三,缩放图片搜索角度时,缩小比例不宜太小(宽度不要小于300),否则笔画特征丢失,投影轮廓失真。第四,如果文档中包含大量图形或大字号标题,它们对投影的影响很大,最好先用先前的方法裁剪中央区域,或者先检测文本区域再单独对文本块计算投影。第五,对于180度旋转(扫描件上下颠倒)的情况,投影轮廓无法区分0度和180度,因为水平投影在180度旋转下不变。这时候需要借助OCR或页头页脚信息判断,不能依赖该算法。

九、延伸:同时使用水平和垂直投影

为了增强稳定性,尤其是在文档中存在明显的垂直表格线时,可以同时计算水平投影和垂直投影的方差,然后将两者的乘积或加权和作为最终得分。这样既要求水平方向文字行清晰,也要求垂直方向列边缘清晰。下面是一个简单的改动示例。


# 技术栈:Python + Pillow + numpy

def robust_score(binary):
    """结合水平和垂直投影的方差"""
    h_proj = binary.sum(axis=1)
    v_proj = binary.sum(axis=0)
    return h_proj.var() * v_proj.var()  # 乘积可以放大差异

# 使用 robust_score 替代原来的 projection_score 即可

当然,如果文档只有纯文本,垂直投影的方差可能主要由行长差异决定,但一般也能用。建议你自己实验,看看哪种组合效果最好。

十、文章总结

今天聊的基于投影轮廓的自动旋转校正,是一个很经典也很“朴素”的图像处理技巧。核心思想就是通过旋转图像,观察投影轮廓的“清晰度”变化,找到最能让文字行水平的角度。整个流程包括:灰度化、二值化、计算投影方差、搜索角度、最终旋转。这个技术不能解决所有扫描件的歪斜问题,但在“文本扫描件”这个场景下,它足够简单、足够快,非常适合批量处理。你完全可以在今天示例的基础上,封装成一个函数,丢到你的图片处理流水线里。如果遇到复杂情况,再结合OCR引擎的置信度回调,或者人工抽检,就能把误判率降到最低。

最后提醒一句,在实际工程中,没有任何一种方法能“通吃”所有图片。投影法的优势在于“快速可用”,当你手头有一堆文档急着处理,又不想上深度学习模型,不妨试试它。当然,如果你追求极致的识别率,还可以在找到粗角后用Hough变换或OCR的字符框中心点拟合直线来微调,但那是另一个话题了。希望这篇文章能帮你打开思路,下次面对歪歪扭扭的扫描件时,心里能有个谱:先用投影轮廓试试看,准错不了。