前言
最近做一个小工具需要用到OCR,一开始用的是tesseract.js这个库,经测试以后发现识别速度实在太慢,识别一张图片基本都要耗时几百毫秒甚至1~2秒,而我的需求对于检测实时性比较高,只有另寻他法。tesseract.js速度慢可能是因为它基于wasm移植,性能损耗比较大;之后我尝试直接使用Tesseract native版本,对比之下速度快了非常多,于是决定使用node Napi 构建本地模块来使用tesseract。
相关代码仓库在https://github.com/ColorfulHorse/Ruminer,ocr native 模块在native/ocr目录下
编译Tesseract
由于Tesseract并没有给我们提供编译好的动态库,所以需要自己从源码编译,官方文档建议的三种方式中 Software Network 和 CPPAN 我试过之后都遇到了一些问题,最后使用Vcpkg来编译。其实也不是非要用包管理器来帮助编译,只不过一般库都有一些子依赖,需要依次下载编译子依赖,不仅麻烦而且容易漏,而包管理器会自动帮我们下载编译目标库的子依赖。
Vcpkg是微软开源的一个c/c++包管理工具,使用比较简单,参照文档下载编译安装:
git clone https://github.com/microsoft/vcpkg
.\vcpkg\bootstrap-vcpkg.bat
之后将Vcpkg配置到环境变量方便使用,同时执行vcpkg integrate install
将它集成到全局。
运行vcpkg search tesseract
可以看到Vcpkg已经收录了tesseract库,有三个版本,由于我们不需要训练数据也不需要交叉编译,直接运行vcpkg install tesseract:x64-windows
安装windows 64位版本就可以了(如果需要编译其他平台的版本参照文档另行修改)。
注意点
Vcpkg初次安装库时需要下载一些组件,比如cmake、nuget这些,如果觉得下载太慢可以set http_proxy=代理
设置临时代理,或者自己手动复制链接下载相应文件复制到vcpkg\downloads目录。然后编译安装这个过程也比较慢,等就是了。
安装完成以后相关文件会在vcpkg\packages\tesseract_x64-windows下,把需要用到的.lib .dll 以及头文件拿出来用就可以了。
构建项目
安装node-gyp
- 安装Python,设置到环境变量
- 安装Visual Studio或者windows-build-tools,我这里安装的是VS2019,推荐安装VS一劳永逸
- 安装node-gyp,
npm install -g node-gyp
,它的作用相当于cmake, 用来构建本地模块。
配置本地模块
1.建立相关目录
在项目目录建立相关文件夹,将tesseract41.lib文件、tesseract头文件拷贝到目录中;由于tesseract依赖leptonica库进行图片处理,我们也需要用到其中一些函数,所以需要将leptonica的头文件一并拷贝过去,所需文件和模块目录如图
2. 下载Tesseract语言包
tesseract检测不同语言需要相应的语言包,官方提供了一些训练好的语言包,下载地址在https://github.com/tesseract-ocr/tessdata
将需要用到的语言包下载放入public目录
3. 编写binding.gyp配置文件
node-gyp根据binding.gpy文件来构建c/c++代码,相当于Cmake的CMakeLists,这里贴一份我的配置,复制的时候记得把注释删掉
{
'targets': [
{
# 模块名称
'target_name': 'ocr',
"cflags!": [ "-fno-exceptions", "-fPIC" ],
"cflags_cc!": [ "-fno-exceptions", "-fPIC" ],
"ldflags": [
# 将当前目录加入到动态库搜索路径,打包时将需要.dll文件全部拷贝到exe文件所在目录
"-Wl,-rpath,'$$ORIGIN'"
],
"sources": [
# 需要编译的源文件
'src/lib.cpp',
'src/index.cpp'
],
"include_dirs": [
# 头文件目录
"
编写代码调用tesseract
我这边的图像源来自屏幕录制,每几百毫秒捕获一次屏幕截图,转成base64字符串丢到c++层调用tesseract进行识别,然后回传结果,代码如下。
lib.h
#ifndef OCR_LIB_H
#define OCR_LIB_H
#include "include/tesseract/baseapi.h"
#include "include/leptonica/allheaders.h"
#include
using namespace std;
using namespace tesseract;
void init(string path);
int loadLanguage(string lang);
string recognize(string base64);
void destroy();
#endif
lib.cpp
#include "include/lib.h"
TessBaseAPI *api = nullptr;
string dataPath = "";
// 初始化
void init(string path) {
if (api == nullptr) {
api = new TessBaseAPI();
}
dataPath = path;
}
// 加载语言包
int loadLanguage(string lang) {
return api->Init(dataPath.c_str(), lang.c_str());
}
// 检测图片中的文字
string recognize(string base64) {
l_int32 size;
l_uint8* source = decodeBase64(base64.c_str(), strlen(base64.c_str()), &size);
PIX * pix = pixReadMem(source, size);
lept_free(source);
api->SetImage(pix);
api->SetSourceResolution(96);
string result = api->GetUTF8Text();
pixDestroy(&pix);
return result;
}
void destroy() {
if (api != nullptr) {
api->End();
delete api;
api = nullptr;
}
dataPath = "";
}
index.cpp 这里用来暴露接口提供给js层
#include
#include
#include "include/lib.h"
using namespace Napi;
void Initialize(const CallbackInfo& info) {
Env env = info.Env();
init(info[0].ToString());
}
Number LoadLang(const CallbackInfo& info) {
Env env = info.Env();
int ret = loadLanguage(info[0].ToString());
return Napi::Number::New(env, ret);
}
String Recognize(const CallbackInfo& info) {
Env env = info.Env();
string text = recognize(info[0].ToString());
String res = Napi::String::New(env, text);
delete[] text.c_str();
return res;
}
void Destroy(const CallbackInfo& info) {
destroy();
}
// 设置类似于 exports = {key:value}的模块导出
Object Init(Env env, Object exports) {
exports["init"] = Function::New(env, Initialize);
exports["loadLanguage"] = Function::New(env, LoadLang);
exports["recognize"] = Function::New(env, Recognize);
exports["destroy"] = Function::New(env, Destroy);
return exports;
}
NODE_API_MODULE(ocr, Init)
编译生成库文件
写好逻辑代码之后打开命令行切换路径到binding.gyp文字所在目录,执行node-gyp configure
, node-gyp build
;顺利的话将会在build/Release目录下生成ocr.node库文件,而它需要依赖的dll文件vcpkg也会帮我们拷贝过来,非常方便。
编写js/ts文件调用本地模块
index.ts
import { loadAddonFile } from '@/utils/NativeUtil'
const ocr = loadAddonFile('src/native/ocr/build/Release/ocr.node', 'ocr.node')
export default {
init: (langPath: string) => {
ocr.init(langPath)
},
loadLanguage: (lang: string): number => {
return ocr.loadLanguage(lang)
},
recognize: (base64: string): Array => {
return ocr.recognize(base64)
},
destroy: () => {
return ocr.destroy()
}
}
注意点
如何使用本地模块,正常情况直接`require(xxx.node)就可以了,由于我的项目使用vue-cli-plugin-electron-builder构建,所以导入时要做一些路径处理
import path from 'path'
declare const __non_webpack_require__: any
export function loadAddonFile(devSrc: string, productSrc: string) {
if (process.env.NODE_ENV !== 'production') {
// 开发环境从项目目录导入
// eslint-disable-next-line
return __non_webpack_require__(path.join(process.cwd(), devSrc))
} else {
// 生产环境从打包根目录导入
// eslint-disable-next-line
return __non_webpack_require__(path.join(process.resourcesPath, '../' + productSrc))
}
}
另外还要将.dll文件拷贝到打包后生成的.exe文件目录下,否则会找不到库,electron-builder配置如下
builderOptions: {
productName: 'xxx',
appId: 'xxx',
copyright: 'xxx',
extraFiles: [
{
// 拷贝dll库文件到打包根目录
from: 'src/native/ocr/build/Release',
to: '.'
}
]
}
一切完成后就可以使用测试一下是否能够正常运行了,如果不正常大概率是导入路径不正确或者缺少一些dll库文件。
提升识别准确度
经过一些测试发现,tesseract基本上只能识别出文字颜色和背景颜色有明显区别,而且背景颜色比较单一的图片,例如白底黑字黑底白字这种,我猜测它对于图片只是做了简单的二值化。但是我的需求比较复杂一点,有时候文字背景比较复杂,背景和文字颜色差别也不是很明显,所以需要对图片做一些前处理提升准确度。这里使用OpenCV来做一些简单的处理,先使用MSER算法配合一些形态学操作检测文本区域,然后裁剪文字区域进行Otsu二值化,最后丢给tesseract进行检测。
基本流程
- 原图转换为灰度图
- 对灰度图做MSER+和MSER-
- 将MSER+和MSER-检测到的区域填充成白色
- 将MSER+和MSER-的结果图取交集
- 交集图做MORPH_CLOSE闭运算操作,消除邻近区域之间的空隙
- 查找区域轮廓,将原图中符合要求的区域裁剪出来
修改后的代码如下
lib.h
#ifndef OCR_LIB_H
#define OCR_LIB_H
#include "include/tesseract/baseapi.h"
#include "include/leptonica/allheaders.h"
#include
#include "opencv2/opencv.hpp"
using namespace std;
using namespace tesseract;
void init(string path);
int loadLanguage(string lang);
vector recognize(string base64);
void destroy();
cv::Mat pixToMat(Pix *pix);
Pix *mat8ToPix(cv::Mat *mat8);
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
std::string base64_decode(std::string const& encoded_string);
std::vector getRect(cv::Mat srcImage);
#endif
lib.cpp
#include "include/lib.h"
TessBaseAPI *api = nullptr;
string dataPath = "";
void init(string path) {
if (api == nullptr) {
api = new TessBaseAPI();
}
dataPath = path;
}
int loadLanguage(string lang) {
int ret = api->Init(dataPath.c_str(), lang.c_str(), OEM_LSTM_ONLY);;
return ret;
}
vector recognize(string base64) {
string decoded_string = base64_decode(base64);
vector data(decoded_string.begin(), decoded_string.end());
cv::Mat img = cv::imdecode(data, cv::IMREAD_UNCHANGED);
cv::Mat gray;
cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
vector rects = getRect(gray);
vector res;
for (size_t i = 0; i < rects.size(); i++) {
cv::Rect rect = rects[i];
cv::Mat area(gray, rect);
// 二值化
cv::threshold(area, area, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
PIX * pix = mat8ToPix(&area);
api->SetImage(pix);
api->SetSourceResolution(96);
string result = api->GetUTF8Text();
pixDestroy(&pix);
res.push_back(result);
}
return res;
}
void destroy() {
if (api != nullptr) {
api->End();
delete api;
api = nullptr;
}
dataPath = "";
}
/**
* Mat灰度图转Pix
*/
Pix *mat8ToPix(cv::Mat *mat8) {
Pix *pixd = pixCreate(mat8->size().width, mat8->size().height, 8);
for(int y=0; yrows; y++) {
for(int x=0; xcols; x++) {
pixSetPixel(pixd, x, y, (l_uint32) mat8->at(y,x));
}
}
return pixd;
}
static inline bool is_base64(unsigned char c) {
return (isalnum(c) || (c == '+') || (c == '/'));
}
/**
* base64解码
*/
std::string base64_decode(std::string const& encoded_string) {
int in_len = encoded_string.size();
int i = 0;
int j = 0;
int in_ = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (encoded_string[in_] != '=') && is_base64(encoded_string[in_])) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i == 4) {
for (i = 0; i < 4; i++)
char_array_4[i] = base64_chars.find(char_array_4[i]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; (i < 3); i++)
ret += char_array_3[i];
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++)
char_array_4[j] = 0;
for (j = 0; j < 4; j++)
char_array_4[j] = base64_chars.find(char_array_4[j]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (j = 0; (j < i - 1); j++) ret += char_array_3[j];
}
return ret;
}
/**
* 检测文本区域
*/
std::vector getRect(cv::Mat gray) {
cv::Mat gray_neg;
// 取反值灰度
gray_neg = 255 - gray;
std::vector > regContours;
std::vector > charContours;//点集
// 创建MSER对象
// _max_variation 最大变化率大于此值的将被忽略
// _min_diversity 两个区域的区别小于此值将被忽略
cv::Ptr mesr1 = cv::MSER::create(5, 20, 5000, 0.5, 0.3);
cv::Ptr mesr2 = cv::MSER::create(5, 20, 400, 0.1, 0.3);
std::vector bboxes1;
std::vector bboxes2;
// MSER+ 检测
mesr1->detectRegions(gray, regContours, bboxes1);
// MSER-操作
mesr2->detectRegions(gray_neg, charContours, bboxes2);
cv::Mat mserMapMat = cv::Mat::zeros(gray.size(), CV_8UC1);
cv::Mat mserNegMapMat = cv::Mat::zeros(gray.size(), CV_8UC1);
for (size_t i = 1; i < regContours.size(); i++) {
// 根据检测区域点生成mser+结果
const std::vector& r = regContours[i];
for (size_t j = 0; j < r.size(); j++) {
cv::Point pt = r[j];
mserMapMat.at(pt) = 255;
}
}
//MSER- 检测
for (size_t i = 1; i < charContours.size(); i++) {
// 根据检测区域点生成mser-结果
const std::vector& r = charContours[i];
for (size_t j = 0; j < r.size(); j++) {
cv::Point pt = r[j];
mserNegMapMat.at(pt) = 255;
}
}
cv::Mat mserResMat;
mserResMat = mserMapMat;
mserResMat = mserMapMat & mserNegMapMat; // mser+与mser-位与操作
// 开运算
cv::Mat mserClosedMat;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(20, 20));
cv::morphologyEx(mserResMat, mserClosedMat,
cv::MORPH_CLOSE, kernel);
// 寻找外部轮廓
std::vector > plate_contours;
cv::findContours(mserClosedMat, plate_contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE, cv::Point(0, 0));
// 候选区域判断输出
std::vector candidates;
for (size_t i = 0; i != plate_contours.size(); ++i) {
// 求解最小外界矩形
cv::Rect rect = cv::boundingRect(plate_contours[i]);
// 宽高比例
double wh_ratio = rect.width / double(rect.height);
if (wh_ratio > 0.5) {
// 忽略太小的区域
if (rect.width > 50) {
// 区域加一些间隔以免字符不完整
const int margin = 5;
int l = rect.x - margin < 0 ? 0 : rect.x - margin;
int t = rect.y - margin < 0 ? 0 : rect.y - margin;
int r = l + rect.width + margin > gray.cols ? gray.cols : l + rect.width + margin;
int b = t + rect.height + margin > gray.rows ? gray.rows : t + rect.height + margin;
cv::Rect rec(l, t, r - l, b - t);
candidates.push_back(rec);
}
}
}
return candidates;
}
index.cpp导出函数也要做一些修改
Array Recognize(const CallbackInfo& info) {
Env env = info.Env();
vector textList = recognize(info[0].ToString());
Array array = Array::New(env);
for (size_t idx = 0; idx < textList.size(); idx++) {
// The HandleScope is recommended especially when the loop has many
// iterations.
Napi::HandleScope scope(env);
array[idx] = Napi::String::New(env, textList[idx]);
}
return array;
}
加入这些优化之后在一定程度上提升了文字识别的准确率,但是如果用于识别自然场景中的文字恐怕还是无能为力,这就要涉及到深度学习领域了,不在本文讨论范围内。
结语
在electron中构建node本地模块还是有不少坑的,一方面对windows的库链接不太熟悉,gyp也不熟悉,一方面node-addon的文档也不太容易读,浪费了不少时间,对一个半吊子来说也可以了;好在最后基本把设想的东西都完成了,收获也很多。