做OCR项目的人几乎都遇到过这样的困扰:同样的识别引擎,用扫描仪扫出来的标准文档识别率能到98%,换成手机随手拍的照片,识别率直接掉到70%以下。问题大概率不在识别模型,而在送进模型的图像质量。倾斜的票据、透视变形的屏幕翻拍、模糊的小字,这些都会让引擎"看不清"。这篇文章聚焦两个最有效的预处理手段——透视矫正和超分辨率,配合完整代码,帮你把识别率实实在在提上来。

为什么预处理对OCR识别率影响这么大
目前主流的OCR引擎,无论是开源的PaddleOCR、Tesseract,还是商用API,其检测和识别模型在训练时使用的大多是规整的合成数据或高质量扫描件。这些图像有几个共同特点:文字区域是水平排列的矩形、分辨率足够高、背景和文字对比清晰。而真实场景中手机拍摄的图像,几乎把这些条件全部破坏了。
具体来说,透视变形是最致命的。当你斜着拍一张发票或从侧面拍一块指示牌时,原本的矩形文字区域在图像中变成了梯形,文字行不再水平,字符还会出现近大远小的拉伸。检测模型可能勉强能框出文字,但识别模型对这种形变极其敏感,尤其是一串数字或字母,稍微拉扯一下就认错了。
其次是分辨率问题。经验上,要让识别模型稳定工作,文字高度至少要有32像素以上,低于16像素基本就靠运气了。很多业务场景里,一张大图中的小字区域,比如合同页脚的备注、车牌上的小字符,直接裁剪出来根本达不到这个标准。这时候超分辨率重建就是必要的补救手段,而不是简单地用最近邻插值放大。
透视矫正:把变形的文档拉回矩形
透视矫正的核心思路分三步:找到目标物体的四个角点,计算透视变换矩阵,然后执行变换。最经典的场景是文档扫描,比如把桌上的一张发票拍正。
第一步的角点检测通常这样做:先转灰度、高斯模糊去噪,然后用Canny算子提取边缘,接着通过findContours找出所有轮廓,选取面积最大的那个(假设文档是画面中最大的物体)。拿到轮廓后需要把所有点拟合成四边形,可以用cv2.approxPolyDP做多边形逼近,也可以用最小外接矩形cv2.minAreaRect。完整代码如下:
import cv2
import numpy as np
def order_points(pts):
# 将四个角点按 左上、右上、右下、左下 排序
rect = np.zeros((4, 2), dtype="float32")
s = pts.sum(axis=1)
rect[0] = pts[np.argmin(s)] # 左上角坐标和最小
rect[2] = pts[np.argmax(s)] # 右下角坐标和最大
d = np.diff(pts, axis=1)
rect[1] = pts[np.argmin(d)] # 右上角差值最小
rect[3] = pts[np.argmax(d)] # 左下角差值最大
return rect
def four_point_transform(image, pts):
rect = order_points(pts)
(tl, tr, br, bl) = rect
# 计算新图像的宽度和高度
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
maxWidth = int(max(widthA, widthB))
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
maxHeight = int(max(heightA, heightB))
# 目标四个角点
dst = np.array([
[0, 0],
[maxWidth - 1, 0],
[maxWidth - 1, maxHeight - 1],
[0, maxHeight - 1]], dtype="float32")
# 计算透视变换矩阵并应用
M = cv2.getPerspectiveTransform(rect, dst)
warped = cv2.warpPerspective(image, M, (maxWidth, maxHeight))
return warped
def find_document(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edged = cv2.Canny(gray, 75, 200)
cnts = cv2.findContours(edged.copy(), cv2.RETR_LIST,
cv2.CHAIN_APPROX_SIMPLE)
cnts = cnts[0] if len(cnts) == 2 else cnts[1]
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:5]
for c in cnts:
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
if len(approx) == 4:
return approx.reshape(4, 2)
return None
img = cv2.imread("invoice.jpg")
doc = find_document(img)
if doc is not None:
result = four_point_transform(img, doc.astype("float32"))
cv2.imwrite("warped.jpg", result)
这段代码里有一个容易被忽略的细节:角点排序。order_points利用坐标和与坐标差的规律来区分四个角,如果排序错了,透视变换矩阵就是错的,输出图像会变成镜像或者完全扭曲的结果。网上很多教程直接把approxPolyDP返回的点喂给变换函数而不排序,在特定拍摄角度下就会翻车。
对于没有明显矩形边界的场景,比如整页倾斜的扫描件,用透视矫正是杀鸡用牛刀,直接用cv2.minAreaRect配合旋转仿射变换就够了,计算量小一个数量级。另外如果拍照角度太斜(超过60度),透视矫正虽然能拉正几何形状,但远离相机的一侧本身像素就少,拉正后会明显模糊,这种情况下应该提示用户重新拍摄,而不是硬靠算法补救。
超分辨率前处理:小字和模糊图像的补救方案
超分辨率的选择要看场景。最简单的方案是OpenCV自带的cv2.resize配合INTER_CUBIC(双三次插值),它计算快但只是平滑插值,不会恢复任何细节,对于本来就模糊的图像提升有限,不过在文字边缘还算清晰、只是尺寸偏小的情况下够用了。
真正意义上的深度学习超分辨率模型,效果明显更好。Real-ESRGAN是目前工程上用得最多的选择,它模拟了真实世界的退化过程(模糊、噪声、JPEG压缩伪影),对手机照片的适配性远好于只在合成数据上训练的SRCNN、EDSR这类早期模型。用法很简单:
from realesrgan import RealESRGANer
from basicsr.archs.rrdbnet_arch import RRDBNet
model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64,
num_block=23, num_grow_ch=32, scale=4)
upsampler = RealESRGANer(
scale=4,
model_path="RealESRGAN_x4plus.pth",
model=model,
tile=400, # 显存不够时分块处理
tile_pad=10,
half=False) # CPU推理时设为False
output, _ = upsampler.enhance(image, outscale=4)
需要注意几个实践要点。第一,超分辨率不要盲目放大到4倍再整图送OCR,更好的做法是先用OCR的检测模型找到文字框,只对小尺寸的文字区域做超分,这样既省算力又能避免大图放大后引入的伪影。第二,Real-ESRGAN是为自然图像设计的,它的输出有时会"过度美化"文字笔画,把细的衬线抹掉。如果发现识别率反而下降,可以试试专门为文字设计的方案,或者把超分结果与原图做加权融合。
第三点是性能权衡。4倍超分一张1080p图像,CPU上可能要十几秒,GPU上也需要几百毫秒。如果业务是批量处理,建议先用插值放大到目标尺寸附近,再判断是否有必要上深度模型。一个简单的判断规则:文字高度在24到32像素之间的区域用双三次插值即可,低于24像素才启用Real-ESRGAN。
完整流水线与效果验证
把前面的环节串起来,一条实用的OCR预处理流水线是这样的:去噪 → 透视或倾斜矫正 → 自适应二值化(可选)→ 文字检测 → 小区域超分 → 识别。去噪放在最前面是因为椒盐噪声会严重干扰Canny边缘检测,导致角点定位失败。二值化建议用cv2.adaptiveThreshold而非全局阈值,能应对光照不均的情况,但如果识别引擎本身对灰度图支持好,这步可以跳过,过度二值化有时会断笔画。
def preprocess_for_ocr(image):
# 1. 快速去噪
denoised = cv2.fastNlMeansDenoisingColored(image, None, 5, 5, 7, 21)
# 2. 尝试透视矫正
doc = find_document(denoised)
if doc is not None:
denoised = four_point_transform(denoised, doc.astype("float32"))
else:
# 退化方案:旋转矫正
gray = cv2.cvtColor(denoised, cv2.COLOR_BGR2GRAY)
coords = np.column_stack(np.where(gray < 255))
angle = cv2.minAreaRect(coords)[-1]
if angle < 45:
angle = -angle
else:
angle = 90 - angle
h, w = image.shape[:2]
M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
denoised = cv2.warpAffine(denoised, M, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
return denoised
最后强调效果验证的重要性。加了预处理不等于识别率一定提升,务必准备一批真实业务图像做A/B对比:分别统计字符级准确率、字段级准确率和平均耗时。经验数据上,对手机拍摄的票据类图像,加透视矫正通常能带来10到20个百分点的提升,超分辨率对小字区域的贡献在5到15个百分点,两者叠加效果基本是累乘而非简单相加。如果某一步指标反而下降,大概率是参数问题,比如Canny阈值不合适导致角点检测失败,或者超分倍数过高引入伪影,针对性调参就能解决。