微信图片文本提取功能的实现

1,625 阅读3分钟

前言

某天,小明在使用微信,他无意中发现图片中的文字可以直接提取复制出来,发现还挺方便。 1683879505286.png 于是,他翻了一下微信的更新日志,发现从 Windows 3.8.0 / Mac 3.6.0 版本起,微信支持提取和翻译图片中的文字内容1683879765657.png 小明对此挺感兴趣,想了解一下它是怎么实现的?

结论先行

使用 光学字符识别模型(OCR) 对图片进行分析,拿到所有字符的坐标信息,然后把字符颜色设置为透明并渲染到对应的坐标位置上。

光学字符识别 (OCR) 是利用计算机视觉和机器学习技术从图像中识别字符的过程。常见用途有身份证、银行卡号码识别等。

4月26日,微信出现打开“打开特殊二维码图片出现应用闪退”的bug,有人分析是因为 OCR 识别系统出现了内存崩溃导致的,该图片导致了微信内存泄露,所以出现闪退崩溃。

微信将OCR模型应用于聊天记录图片搜索、表情包搜索。

1.jpg

实现方案

调研

经过调研,有以下 OCR 模型的 JavaScript 库可以采用:

JavaScrip库特点
Tesseract.js该库支持超过100种语言,自动文本定位和脚本检测,用于阅读段落、单词和字符包围框的简单界面。Tesseract.js既可以运行在浏览器中,也可以运行在带有Node.JS的服务器上
textract支持多种格式文件的文字提取:HTML、PDF、DOC、CSV、PPTX、PNG、JPG 、GIF、All text/*、mime-types 等。识别PNG、JPG、GIF 时需要使用 tesseract 进行识别

除了上述两个 JavaScript 库,google 还推出的相关的 SDK 用于移动设备(ios、安卓):

SDK特点
tensorflow当前的文本识别模型使用英文字母和数字的合成数据进行训练,因此只支持英语和数字,不支持中文
Google ML Kit一个移动 SDK,将 Google 的设备端机器学习专业知识运用于 Android 和 iOS 应用

尝试

1. Tesseract.js 初体验

// 重写log,将结果输出到log文件,方便查看输出结果
const fs = require('fs')
const logFile = fs.createWriteStream('./log/index.log', { flags: 'w', encoding:'utf8' })
const logger = new console.Console(logFile)
​
// 1.简约写法,每次运行都会创建一个新的worker并加载语言库,低性能。
Tesseract.recognize(
  'https://tesseract.projectnaptha.com/img/chi_sim.png',
  'chi_sim'
).then(({ data }) => {
  logger.log(data)
})
​
// 2.使用worker,将创建worker和加载语言库与识别工作分离,提前初始化,且worker可以重复利用。
;(async () => {
  const Tesseract = require('tesseract.js')
  const worker = await Tesseract.createWorker() // 创建worker
  await worker.loadLanguage('chi_sim') // 加载语言
  await worker.initialize('chi_sim') // worker初始化
  const { data } = await worker.recognize('https://tesseract.projectnaptha.com/img/chi_sim.png')
  logger.log(data)
  await worker.terminate() // 销毁worker
})()

上面代码初次运行耗时较长,同时运行后会生成一个对应语言库的二进制文件,后续同语言的识别工作都会使用该语言库,所以二次运行速度较快。

1684226343882.png

2. textract初体验

因为 textract 识别 PNG、JPG、GIF 需要依赖 tesseract,所以它跟 Tesseract.js 的使用效果是一样的,因此就不用它来识别图片了,可以尝试一下其他类型的文件文字提取。(其实是因为它需要系统安装 tesseract,然后 tesseract 的安装又依赖 Python 环境,所以就偷懒一下^~^)

// 重写log,将结果输出到log文件,方便查看输出结果
const fs = require('fs')
const logFile = fs.createWriteStream('./log/index.log', { flags: 'w', encoding:'utf8' })
const logger = new console.Console(logFile)
​
const textract = require('textract')
textract.fromUrl('https://www.tabnine.com/code/javascript/modules/textract',
  { preserveLineBreaks: true },
  (err, page) => {
    if (err) {
      console.log(err)
      return
    }
    logger.log(page)
  }
)

3. Vue中使用Tesseract.js

<script lang="ts" setup>
import { ref } from 'vue'
import Tesseract from 'tesseract.js'
const hocr = ref('')
const imgUrl = 'https://tesseract.projectnaptha.com/img/chi_sim.png'
​
// 获取每一行的样式, 字体大小和位置
const getLineStyle = (line: Tesseract.Line) => {
  const { bbox } = line
  return `font-size: ${(bbox.y1 - bbox.y0)}px; left: ${bbox.x0}px; top: ${bbox.y0}px;`
}
​
;(async () => {
  const worker = await Tesseract.createWorker() // 创建worker
  await worker.loadLanguage('chi_sim') // 加载语言
  await worker.initialize('chi_sim') // worker初始化
  const { data } = await worker.recognize(imgUrl)
  await worker.terminate() // 销毁worker
  let newHocr = ''
  data.lines.forEach(line => {
    newHocr += `<div style="${getLineStyle(line)}" class="line">${line.text.replaceAll(' ', '')}</div>`
  })
  hocr.value = newHocr
})()
</script>
​
<template>
  <div>
    <div class="img-container">
      <img :src="imgUrl" alt="" class="img">
      <div v-html="hocr"></div>
    </div>
  </div>
</template>
​
<style lang="scss" scoped>
.img-container {
  display: inline-block;
  position: relative;
​
  .img::selection {
    background: none;
  }
​
  :deep(.line) {
    position: absolute;
    color: transparent;
    z-index: 100;
  }
​
  :deep(.line::selection) {
    background: #afd3fb99;
  }
}
</style>

效果分析

JavaScrip库缺点
Tesseract.js文字识别正确率较低、性能低、单次只能识别一种语言
textract没有返回坐标位置

较优方案

以上演示了使用 Tesseract.js 来进行图片识别,但是也暴露了一些问题:

  • 文字识别正确率较低
  • 单次只能识别一种语言,不能进行混合字符识别,如:中英文
  • 加载资源和识别过程较慢

所以在实际生产中,更多的是后端或者直接调用第三方(如:腾讯云阿里云百度云等)接口处理这些工作。

某天,在掘金论坛“鬼混”的时候刚好看到一篇文章《揭秘语雀文档为何能搜索到图片里的文字内容》,里面写到语雀的图片上传接口集合了图片 OCR 的能力。

于是我一顿操作,注册账号、新建团队、创建文档,尝试了一下图片上传,在控制台抓到了它的接口。那接下来就白嫖一下它的接口更优雅地实现上面的效果。

项目使用 Vue3.x + vite + typeScript 搭建:

接口类型声明

interface UploadResponse {
  data: {
    filekey: string
    extname: string
    mode: string
    res: {
      status: number
      statusCode: number
      statusMessage: string
      headers: Record<string, unknown>
      size: number
      aborted: false
      rt: number
      keepAliveSocket: true
      data: {
        type: string
        data: []
      }
      requestUrls: [
        string
      ]
      timing: {
        queuing: number
        dnslookup: number
        connected: number
        requestSent: number
        waiting: number
        contentDownload: number
      }
      remoteAddress: string
      remotePort: number
      socketHandledRequests: number
      socketHandledResponses: number
      isSymlink: true
    }
    url: string
    filename: string
    size: number
    etag: string
    symlink: number
    isCopy: true
    filemd5: string
    element_id: null
    attachment_id: number
    attachable_type: string
    attachable_id: number
    attachment: {
      filesize: number
      id: number
      created_at: string
      updated_at: string
      space_id: number
      attachable_type: string
      attachable_id: number
      user_id: number
      filekey: string
      filemd5: string
      filename: string
      ext: string
      mode: string
      symlink: number
      filesize_big: number
    }
    ocrLocations: Location[]
  }
}
​
interface Location {
  x: number
  y: number
  width: number
  height: number
  text: string
}
​
interface ImageOcr {
  url: string
  hocr: string
}

组件代码

<template>
  <div>
    <!-- action的值请查看调试器 -->
    <el-upload
      action="/api/upload/attach?..."
      accept="images/*"
      :before-upload="beforeUpload"
      :on-success="uploadSuccess"
      :show-file-list="false"
    >
      <el-button type="primary">
        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
        上传
      </el-button>
    </el-upload>
    <div v-for="item in images" class="img-container">
      <img :src="item.url" alt="" crossorigin="anonymous" class="img">
      <div v-html="item.hocr" />
    </div>
  </div>
</template>
​
<script setup lang="ts">
import { ref } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import type { UploadRawFile } from 'element-plus'
​
const images = ref<ImageOcr[]>([])
const current = ref('')
​
const beforeUpload = (file: UploadRawFile) => {
  current.value = URL.createObjectURL(file)
  return true
}
​
// 获取每一行的样式
const getLineStyle = (line: Location) => {
  const size = Math.min(line.height, line.width / line.text.length)
  const spacing = line.width / line.text.length - size
  return `font-size: ${size}px; left: ${line.x}px; top: ${line.y}px;` + (spacing > 0 ? `letter-spacing: ${spacing}px;` : '')
}
​
const uploadSuccess = (res: UploadResponse) => {
  const { data } = res
  let newHocr = ''
  data.ocrLocations.forEach(line => {
    newHocr += `<div style="${getLineStyle(line)}" class="line">${line.text}</div>`
  })
  images.value.push({ url: current.value, hocr: newHocr })
}
</script>
​
<style lang="scss" scoped>
.img-container {
  position: relative;
  font-size: 0;
​
  .img::selection {
    background: none;
  }
​
  :deep(.line) {
    position: absolute;
    color: transparent;
    z-index: 100;
  }
​
  :deep(.line::selection) {
    background: #afd3fb99;
  }
}
</style>

vite.config.ts

export default defineConfig({
  plugins: [
    vue()
  ],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, 'src')
    }
  },
  server: {
    // 代理配置
    proxy: {
      '/api': {
        target: 'https://www.yuque.com',
        changeOrigin: true,
        headers: {
          // 语雀接口校验需要该属性,具体值可以查看调试器
          referer: 'https://www.yuque.com/...'
        }
      }
    }
  }
})

在请求响应中,可以看到一个名为 ocrLocations 的数组属性,接口会将文字分成多段,并返回每一段文字的文本、xy坐标、宽高信息。 1684291073182.png

  • 使用 height 作 font-size: 1684292107427.png

  • 使用 line.width / line.text.length 作 font-size: 1684292281482.png

上述两种方案在样式设置上都具有一定问题,将二者进行结合得到下述样式获取代码:

// 这里将每段文字的min(高, 宽/文字数)当做文字大小,单词间隔为(宽/文字数 - 文字大小)
const getLineStyle = (line: Location) => {
  const size = Math.min(line.height, line.width / line.text.length)
  const spacing = line.width / line.text.length - size
  return `font-size: ${size}px; left: ${line.x}px; top: ${line.y}px;` + (spacing > 0 ? `letter-spacing: ${spacing}px;` : '')
}

优化后得到效果都比前两种方案好: css_sprites.png 至此,我们就完成了调用语雀的接口实现图片文字提取的功能。

前端小白一名,不喜勿喷,若喜欢请给个赞!