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": [
        # 头文件目录
        "<!@(node -p \"require('node-addon-api').include\")",
        "src/include"
      ],
      "dependencies":[
        # node本身需要的依赖
        "<!(node -p \"require('node-addon-api').gyp\")"
      ],
      'defines': [
        'NAPI_DISABLE_CPP_EXCEPTIONS'
      ],
      "conditions": [
        ["OS=='win'",
          {
            "libraries": [
              # 需要链接的依赖库,我这里另外用到了opencv做前处理
              "-l<(module_root_dir)/libs/tesseract41.lib",
              "-l<(module_root_dir)/libs/opencv_imgproc.lib",
              "-l<(module_root_dir)/libs/opencv_features2d.lib"
            ]
          }
        ]
      ]
    }
  ]
}

编写代码调用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 <string>

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 <napi.h>
#include <string>
#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<string> => {
    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 <string>
#include "opencv2/opencv.hpp"

using namespace std;
using namespace tesseract;

void init(string path);

int loadLanguage(string lang);

vector<string> 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<cv::Rect> 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<string> recognize(string base64) {
    string decoded_string = base64_decode(base64);
    vector<uchar> 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<cv::Rect> rects = getRect(gray);
    vector<string> 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; y<mat8->rows; y++) {
        for(int x=0; x<mat8->cols; x++) {
            pixSetPixel(pixd, x, y, (l_uint32) mat8->at<uchar>(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<cv::Rect> getRect(cv::Mat gray) {
    cv::Mat gray_neg;
    // 取反值灰度
    gray_neg = 255 - gray;
    std::vector<vector<cv::Point> > regContours;
    std::vector<vector<cv::Point> > charContours;//点集

    // 创建MSER对象
    // _max_variation 最大变化率大于此值的将被忽略
    // _min_diversity 两个区域的区别小于此值将被忽略
    cv::Ptr<cv::MSER> mesr1 = cv::MSER::create(5, 20, 5000, 0.5, 0.3);
    cv::Ptr<cv::MSER> mesr2 = cv::MSER::create(5, 20, 400, 0.1, 0.3);
    std::vector<cv::Rect> bboxes1;
    std::vector<cv::Rect> 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<cv::Point>& r = regContours[i];
        for (size_t j = 0; j < r.size(); j++) {
            cv::Point pt = r[j];
            mserMapMat.at<unsigned char>(pt) = 255;
        }
    }
    //MSER- 检测
    for (size_t i = 1; i < charContours.size(); i++) {
        // 根据检测区域点生成mser-结果
        const std::vector<cv::Point>& r = charContours[i];
        for (size_t j = 0; j < r.size(); j++) {
            cv::Point pt = r[j];
            mserNegMapMat.at<unsigned char>(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<std::vector<cv::Point> > plate_contours;
    cv::findContours(mserClosedMat, plate_contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE, cv::Point(0, 0));
    // 候选区域判断输出
    std::vector<cv::Rect> 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<string> 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的文档也不太容易读,浪费了不少时间,对一个半吊子来说也可以了;好在最后基本把设想的东西都完成了,收获也很多。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容