前言
某天,小明在使用微信,他无意中发现图片中的文字可以直接提取复制出来,发现还挺方便。
于是,他翻了一下微信的更新日志,发现从 Windows 3.8.0 / Mac 3.6.0 版本起,微信支持提取和翻译图片中的文字内容。
小明对此挺感兴趣,想了解一下它是怎么实现的?
结论先行
使用 光学字符识别模型(OCR) 对图片进行分析,拿到所有字符的坐标信息,然后把字符颜色设置为透明并渲染到对应的坐标位置上。
光学字符识别 (OCR) 是利用计算机视觉和机器学习技术从图像中识别字符的过程。常见用途有身份证、银行卡号码识别等。
4月26日,微信出现打开“打开特殊二维码图片出现应用闪退”的bug,有人分析是因为 OCR 识别系统出现了内存崩溃导致的,该图片导致了微信内存泄露,所以出现闪退崩溃。
微信将OCR模型应用于聊天记录图片搜索、表情包搜索。
实现方案
调研
经过调研,有以下 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
})()
上面代码初次运行耗时较长,同时运行后会生成一个对应语言库的二进制文件,后续同语言的识别工作都会使用该语言库,所以二次运行速度较快。
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坐标、宽高信息。
-
使用 height 作 font-size:
-
使用 line.width / line.text.length 作 font-size:
上述两种方案在样式设置上都具有一定问题,将二者进行结合得到下述样式获取代码:
// 这里将每段文字的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;` : '')
}
优化后得到效果都比前两种方案好:
至此,我们就完成了调用语雀的接口实现图片文字提取的功能。
前端小白一名,不喜勿喷,若喜欢请给个赞!