Electron通过Napi调用Tessract实现文字识别(含编译库和提升识别准确度)

前言

最近做一个小工具需要用到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位版本就可以了(如果需要编译其他平台的版本参照文档另行修改)。

库版本.png

注意点

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的头文件一并拷贝过去,所需文件和模块目录如图

tesseractlib.png
tesseract_include.png
leptonica_include.png
dir.png
2. 下载Tesseract语言包

tesseract检测不同语言需要相应的语言包,官方提供了一些训练好的语言包,下载地址在https://github.com/tesseract-ocr/tessdata

将需要用到的语言包下载放入public目录

tess_data.png
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 configurenode-gyp build;顺利的话将会在build/Release目录下生成ocr.node库文件,而它需要依赖的dll文件vcpkg也会帮我们拷贝过来,非常方便。

ocr.node.png

编写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的文档也不太容易读,浪费了不少时间,对一个半吊子来说也可以了;好在最后基本把设想的东西都完成了,收获也很多。

你可能感兴趣的:(Electron通过Napi调用Tessract实现文字识别(含编译库和提升识别准确度))