Published on

高精度 OCR 图片文字 AI 消除

Authors

背景

我们需要一个工具将图片中的文字进行去除,传统的手段对于文字去除的效果并不好,尤其是遇到复杂场景,往往需要复杂的图像处理手段来进行处理,而且可能还需要“人工”智能来帮忙。

我们尝试了 ffmpeg 的方式对文字进行去除,但是只有文字在纯色或者简单背景下 ffmpeg 的处理才能说的过去。

随后,我们在 Github 上找到了 IOPaint(原 lama-cleaner) 这个项目,他能借助 AI 模型的能力帮助我们实现良好的图片处理效果。

IOPaint(lama-cleaner)

IOPaint is a Image inpainting tool powered by SOTA AI Model. Remove any unwanted object, defect, people from your pictures or erase and replace(powered by stable diffusion) any thing on your pictures.
项目地址 | 官方网站

IOPaint 提供了一系列的图像处理 AI 能力,包括涂抹、inpaint、outpainting 等等,非常好用,而且成本不高。可以选择使用不同的模型。

Version 1

第一个版本很简单,我们使用 PaddleOCR 识别出了图片中的文字位置,然后将这个位置生成成一个模版给到 IOPaint 的 API。

from paddleocr import PaddleOCR

def ocr(image):
# 使用 PaddleOCR 识别图片中的文字
    result = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False).ocr(image, cls=True)[0]
    return result

def inpainting(origin_image_bytes, words, width, height):
# 生成 IOPaint 的模版并调用 IOPaint 的 API
# origin_image_bytes: 原始图片的字节流
# words: 识别出的文字位置
# width: 图片宽度
# height: 图片高度
    mask = Image.new("RGB", (width, height), "black")
    draw = ImageDraw.Draw(mask)

    for word in words:
        draw.rectangle(word["inpainting_position"], fill="white")

    with io.BytesIO() as output:
        mask.save(output, format='PNG')  # 保存为PNG格式的字节流
        mask_data = output.getvalue()

    r = requests.post(inpaint_api_url,
                      files={
                          "image": bytearray(origin_image_bytes),
                          "mask": mask_data
                      },
                      data={
                          "ldmSteps": 25,
                          "ldmSampler": "plms",
                          "zitsWireframe": True,
                          "hdStrategy": "Crop",
                          "hdStrategyCropMargin": 196,
                          "hdStrategyCropTrigerSize": 800,
                          "hdStrategyResizeLimit": 2048,
                          "croperX": 256,
                          "croperY": 256,
                          "croperHeight": 512,
                          "croperWidth": 512,
                          "useCroper": False,
                          "sdMaskBlur": 5,
                          "sdStrength": 0.75,
                          "sdSteps": 50,
                          "sdGuidanceScale": 7.5,
                          "sdSampler": "uni_pc",
                          "sdSeed": -1,
                          "sdMatchHistograms": False,
                          "sdScale": 1,
                          "cv2Radius": 5,
                          "cv2Flag": "INPAINT_NS",
                          "paintByExampleSteps": 50,
                          "paintByExampleGuidanceScale": 7.5,
                          "paintByExampleSeed": -1,
                          "paintByExampleMaskBlur": 5,
                          "paintByExampleMatchHistograms": False,
                          "p2pSteps": 50,
                          "p2pImageGuidanceScale": 1.5,
                          "p2pGuidanceScale": 7.5,
                          "controlnet_conditioning_scale": 0.4,
                          "controlnet_method": "control_v11p_sd15_canny",
                          "prompt": "",
                          "negativePrompt": "",
                          "sizeLimit": max(width, height)
                      }
                      )
    return r.content

中间的这些参数你可以在 IOPaint 的 Web 应用调用的 API 中获取,你可以自己设置不同的参数获取不同的效果。

IOPaint 的效果非常好,我们 70% 的图片都能达到我们的预期效果,但是还有 30% 的图片效果不好,我们需要进一步优化。

good_case_1 good_case_2 bad_case

可以看到第三张效果图中,文字的边缘有一些残留,部分文字没有被涂抹干净,主要的原因是因为 PaddleOCR 给出的文字位置有可能紧贴着文字,也有可能正好卡在文字后面的背景框上(其他图片会有此类问题)

图中蓝色的框是 OCR 识别的区域,红色的框是我们最初版本重新计算的区域。

我们统计了第一批测试的 100 多张图片的效果,总结了会碰到的一些具体情况:

  1. 字号偏大的斜体文字,会出现涂抹不全的问题
  2. 有颜色 border 的文字
  3. 有复杂的、不规律的背景色块,容易出现涂坏的效果
  4. 文字如果出现在人脸上,IOPaint 也无法正确处理
  5. 小字号文字带有比较紧贴的背景边框或颜色背景时,容易涂坏
  6. 小字号的纵向文字
  7. 带括号的文字
  8. 文字出现在物体上的情况
  9. 文字出现在物体边上的情况

同时我们邀请了 5 位同学对某一类目下的图片进行结果评估标准,用来初步建立我们的效果评估体系。

问题分析

  1. 识别出来的边框过于紧贴文字,会导致涂抹的时候出现问题
  2. 符号会超出识别出来的边框,字号越大越明显
  3. 小字号的文字容易出现识别出来的边框范围过大,会导致涂抹时触及到背景的元素
  4. 人脸上 IOPaint 无法正确处理,没有合适解法
  5. 【8】、【9】问题的原因是因为算法问题导致了涂抹的区域横向过大,导致了涂抹到了物体上

关于为什么字号越大边框会越有问题,这里可以了解一下文字渲染的原理:LearnOpenGL - 文字渲染

解决方案

首先,我们需要扩大 OCR 出来的文字边框,避免边框贴住文字的情况,但是扩大多少,是一个问题。

那么首先我们使用固定系数来扩大涂抹的区域,包括 IOPaint 的 Demo 也是这么做的,显然这样的效果并不会好。

那我们应该怎么来扩大这个区域呢?

  • 首页我们需要判断边框是否完全框住了我们的文字内容,如果没有我们应该让边框向外扩展至完全框住;

  • 然后,我们希望区域越大扩大的越少,区域越小扩大的越多,同时有一个最大的限制避免区域过大;

OK,看下代码怎么实现

def auto_spread(canny_image, start, f, t, axis, word, threshold, move=-1):
    # 自动扩展边框
    # canny_image: 二值化原图
    # start: 开始位置
    # f: 开始位置偏移量
    # t: 结束位置偏移量
    # axis: x or y
    # word: 识别出来的文字位置
    # threshold: 扩展的阈值
    # move: 扩展的方向
    width, height = canny_image.size
    init_start = start
    while True:
        if start - threshold == 0 or start <= 0 or start >= (width if axis == "x" else height):
            return start
        colors = []
        for i in range(0, round((t - f) / 2)):
            colors.append(canny_image.getpixel(make_spread_point(axis, start, f + i)))
            colors.append(canny_image.getpixel(make_spread_point(axis, start, t - i)))

        black_count = len(list(filter(lambda x: x == 255, colors)))
        if not 255 in colors:
            return start

        start = start + move
# 计算 X 轴的最大扩展值
# max_x - x 计算边框的宽度
# x_offset_min 为最小的扩展值, 默认是 3 个像素
# x_offset_log 为扩展值的对数底,默认是 0.5
# width 为图片的宽度
# 使用识别宽度 / 图片宽度来定义识别区域的“大、小”,使用对数函数来控制扩展值的变化,越大的比例,扩展值越小
x_offset = round((max_x - x) * max(x_offset_min, int(math.log(((max_x - x) / width), x_offset_log))) / 10 / 2)

# 自动扩展边框,保证边框完全框住文字,x 轴左右两边分开计算
new_x = auto_spread(canny_image, x, y, max_y, "x", word["word"], x - x_offset, -1)
new_max_x = auto_spread(canny_image, max_x, y, max_y, "x", word["word"], max_x + x_offset, 1)

函数曲线如图所示:

横轴为识别宽度 / 图片宽度

函数曲线
# 计算 X 轴扩大的像素与识别宽度的比例
x_increase_rate = (x - new_x) / (max_x - x)
max_x_increase_rate = (new_max_x - max_x) / (max_x - x)

# 计算最终的扩大值
# fs_x_default: 当扩散值小于等于最小扩散阈值时,使用此默认值,默认是3像素
# fs_x_default_threhold: 最小扩散阈值, 默认0
# fs_x_direct_mutiple: 当扩散比例大于最大扩散比例阈值时,使用此倍数,默认是 0.5
# fs_x_rate_threhold: 最大扩散比例阈值,默认值 0.03
# fs_x_log: 计算对数的底,默认是 0.5
# fs_x_max_threhold: 最大扩散值比例阈值,默认是 3
r_x = new_x - (fs_x_default if x - new_x <= fs_x_default_threhold else (x - new_x) * (
        fs_x_direct_mutiple if x_increase_rate > fs_x_rate_threhold else math.log(x_increase_rate, fs_x_log) - fs_x_max_threhold))

r_max_x = new_max_x + (fs_mx_default if new_max_x - max_x <= fs_mx_default_threhold else (new_max_x - max_x) * (
        fs_mx_direct_mutiple if max_x_increase_rate > fs_mx_rate_threhold else math.log(max_x_increase_rate, fs_mx_log) - fs_mx_max_threhold))

最大扩散值比例曲线所示:

横轴为扩散比例

函数曲线

Y 轴同理,共记 29 个参数,这里不再赘述。

下面是最后的效果

good_case_3

建立测试集

有了这 29 个参数,我们可以对效果作出更加细微的调整,但是怎么看调整的效果呢?怎么确保这次的改动对之前的效果产生影响呢?不可能每次都把所有图片都运行一遍。

我们需要一个测试工具,能够对测试的改动进行快速高效的评估。

所以我们针对生产环境中出现问题的错误图片建立了“错题集”。随着生产环境的不断更新,这个错题集也会变得越来越完善。

我们使用 jupyter notebook 建立了一个测试环境,每个参数的改动都会对“错题集”中的图片进行测试,测试效果一目了然。

结束

通过上面的方法加上一个星期对参数的调整,我们基本实现 90% 的图片可以直出(无需人工调整)。

预告

后续,我们还需要实现将原来的文字翻译成目标语言并贴回图片中,这里我们会遇到文字颜色提取的难题,下篇文章来分享这个方案。